From c3f9d8e41bf2d23f676a10ec4579434a94b1fc39 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 8 Sep 2014 10:28:07 +0200 Subject: [PATCH 001/108] BF: Made notification work again (forgot to renamed "offline" to "unavailable") --- webclient/room/room-controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c738b490e2..1c496d7213 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -63,7 +63,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) if (window.Notification) { // Show notification when the user is idle - if (matrixService.presence.offline === mPresence.getState()) { + if (matrixService.presence.unavailable === mPresence.getState()) { var notification = new window.Notification( ($scope.members[event.user_id].displayname || event.user_id) + " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here From 24f0bb4af5c2863c13c015f4a091ff0c84960a67 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 8 Sep 2014 11:09:14 +0200 Subject: [PATCH 002/108] Revert "BF: Made notification work again (forgot to renamed "offline" to "unavailable")" This reverts commit c3f9d8e41bf2d23f676a10ec4579434a94b1fc39. --- webclient/room/room-controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 1c496d7213..c738b490e2 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -63,7 +63,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) if (window.Notification) { // Show notification when the user is idle - if (matrixService.presence.unavailable === mPresence.getState()) { + if (matrixService.presence.offline === mPresence.getState()) { var notification = new window.Notification( ($scope.members[event.user_id].displayname || event.user_id) + " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here From 7bff9b62699c371c033203c9e132604aacd4f044 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 5 Sep 2014 12:46:48 -0700 Subject: [PATCH 003/108] Minor spec tweaks. --- docs/specification.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/specification.rst b/docs/specification.rst index d8189996cf..b15792c00d 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -755,15 +755,17 @@ There are several APIs provided to ``GET`` events for a room: Description: Get all ``m.room.member`` state events. Response format: - ``{ "start": "token", "end": "token", "chunk": [ { m.room.member event }, ... ] }`` + ``{ "start": "", "end": "", "chunk": [ { m.room.member event }, ... ] }`` Example: TODO |/rooms//messages|_ Description: - Get all ``m.room.message`` events. + Get all ``m.room.message`` and ``m.room.member`` events. This API supports pagination + using ``from`` and ``to`` query parameters, coupled with the ``start`` and ``end`` + tokens from an |initialSync|_ API. Response format: - ``{ TODO }`` + ``{ "start": "", "end": "" }`` Example: TODO From 7735aad9d67e6e20ad61977e54c9ff2447623804 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Sat, 6 Sep 2014 17:27:42 +0100 Subject: [PATCH 004/108] Bump version and changelog --- CHANGES.rst | 21 +++++++++++++++++++++ VERSION | 2 +- synapse/__init__.py | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 31eee891da..8824ece5a9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,24 @@ +Changes in synapse 0.2.2 (2014-09-06) +===================================== + +Homeserver: + * When the server returns state events it now also includes the previous + content. + * Add support for inviting people when creating a new room. + * Make the homeserver inform the room via `m.room.aliases` when a new alias + is added for a room. + * Validate `m.room.power_level` events. + +Webclient: + * Add support for captchas on registration. + * Handle `m.room.aliases` events. + * Asynchronously send messages and show a local echo. + * Inform the UI when a message failed to send. + * Only autoscroll on receiving a new message if the user was already at the + bottom of the screen. + * Add support for ban/kick reasons. + * Fix bug where we occaisonally saw duplicated join messages. + Changes in synapse 0.2.1 (2014-09-03) ===================================== diff --git a/VERSION b/VERSION index 0c62199f16..ee1372d33a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/synapse/__init__.py b/synapse/__init__.py index 440e633966..1ed9cdcdf3 100644 --- a/synapse/__init__.py +++ b/synapse/__init__.py @@ -16,4 +16,4 @@ """ This is a reference implementation of a synapse home server. """ -__version__ = "0.2.1" +__version__ = "0.2.2" From 768ff1a850a74141c67f643e46b26884cd149837 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Sat, 6 Sep 2014 17:38:11 +0100 Subject: [PATCH 005/108] Fix race in presence handler where we evicted things from cache while handling a key therein --- synapse/handlers/presence.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index c79bb6ff76..b2af09f090 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -796,11 +796,12 @@ class PresenceEventSource(object): updates = [] # TODO(paul): use a DeferredList ? How to limit concurrency. for observed_user in cachemap.keys(): - if not (from_key < cachemap[observed_user].serial): + cached = cachemap[observed_user] + if not (from_key < cached.serial): continue if (yield self.is_visible(observer_user, observed_user)): - updates.append((observed_user, cachemap[observed_user])) + updates.append((observed_user, cached)) # TODO(paul): limit From f397b2264c705a8284ca08890857782353f3b648 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Sat, 6 Sep 2014 09:47:30 -0700 Subject: [PATCH 006/108] https when loading recaptcha js --- webclient/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/index.html b/webclient/index.html index baaaa8cef8..81c7c7d06c 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -11,7 +11,7 @@ - + From cde6bdfa77a8383fa4042b94ef45646e37b83c50 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Sat, 6 Sep 2014 09:53:39 -0700 Subject: [PATCH 007/108] Use the room_display_name when presenting on the home page, and not the room_alias which may not be set. --- webclient/home/home.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/home/home.html b/webclient/home/home.html index c1f9643839..7240e79f86 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -26,7 +26,7 @@
From dd2ae6412088a30a7a2eecd7d8ba155f2b8396ff Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Sat, 6 Sep 2014 09:57:13 -0700 Subject: [PATCH 008/108] Set the room_alias field when we encounter a new one, rather than only from local storage. --- webclient/components/matrix/matrix-service.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index a1b9691f05..3c28c52fbe 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -525,7 +525,6 @@ angular.module('matrixService', []) room_alias: undefined, room_display_name: undefined }; - var alias = this.getRoomIdToAliasMapping(room.room_id); if (alias) { // use the existing alias from storage @@ -539,6 +538,7 @@ angular.module('matrixService', []) // TODO: select the smarter alias from the array this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]); result.room_display_name = room.aliases[0]; + result.room_alias = room.aliases[0]; } else if (room.membership === "invite" && "inviter" in room) { result.room_display_name = room.inviter + "'s room"; @@ -551,7 +551,6 @@ angular.module('matrixService', []) }, createRoomIdToAliasMapping: function(roomId, alias) { - //console.log("creating mapping between " + roomId + " and " + alias); roomIdToAlias[roomId] = alias; aliasToRoomId[alias] = roomId; // localStorage.setItem(MAPPING_PREFIX+roomId, alias); From ef0304beff82ad985033405e7230a327a91fc796 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 6 Sep 2014 10:13:38 -0700 Subject: [PATCH 009/108] disable broken event dup suppression, and fix echo for /me --- .../matrix/event-handler-service.js | 11 +++++-- webclient/room/room-controller.js | 30 ++++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index cd4f2ccf28..d2bb31053f 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -79,7 +79,8 @@ angular.module('eventHandlerService', []) initRoom(event.room_id); if (isLiveEvent) { - if (event.user_id === matrixService.config().user_id) { + if (event.user_id === matrixService.config().user_id && + (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) { // assume we've already echoed it // FIXME: track events by ID and ungrey the right message to show it's been delivered } @@ -162,11 +163,17 @@ angular.module('eventHandlerService', []) NAME_EVENT: NAME_EVENT, handleEvent: function(event, isLiveEvent) { + // FIXME: event duplication suppression is all broken as the code currently expect to handles + // events multiple times to get their side-effects... +/* if (eventMap[event.event_id]) { console.log("discarding duplicate event: " + JSON.stringify(event)); return; } - + else { + eventMap[event.event_id] = 1; + } +*/ if (event.type.indexOf('m.call.') === 0) { handleCallEvent(event, isLiveEvent); } diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c738b490e2..e69adb9b46 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -302,7 +302,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) scrollToBottom(true); var promise; - var isCmd = false; + var cmd; + var args; + var echo = false; // Check for IRC style commands first var line = $scope.textInput; @@ -311,17 +313,16 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) line = line.replace(/\s+$/, ""); if (line[0] === "/" && line[1] !== "/") { - isCmd = true; - var bits = line.match(/^(\S+?)( +(.*))?$/); - var cmd = bits[1]; - var args = bits[3]; + cmd = bits[1]; + args = bits[3]; console.log("cmd: " + cmd + ", args: " + args); switch (cmd) { case "/me": promise = matrixService.sendEmoteMessage($scope.room_id, args); + echo = true; break; case "/nick": @@ -453,17 +454,20 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) } // By default send this as a message unless it's an IRC-style command - if (!promise && !isCmd) { - var message = $scope.textInput; - $scope.textInput = ""; - + if (!promise && !cmd) { + // Make the request + promise = matrixService.sendTextMessage($scope.room_id, line); + echo = true; + } + + if (echo) { // Echo the message to the room // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages var echoMessage = { content: { - body: message, + body: (cmd === "/me" ? args : line), hsob_ts: new Date().getTime(), // fake a timestamp - msgtype: "m.text" + msgtype: (cmd === "/me" ? "m.emote" : "m.text"), }, room_id: $scope.room_id, type: "m.room.message", @@ -472,11 +476,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML }; + $scope.textInput = ""; $rootScope.events.rooms[$scope.room_id].messages.push(echoMessage); scrollToBottom(); - - // Make the request - promise = matrixService.sendTextMessage($scope.room_id, message); } if (promise) { From 2df5cb114dd3c8686c4217dcbc0797e67c0b7b7a Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Sat, 6 Sep 2014 18:14:56 +0100 Subject: [PATCH 010/108] Remove disabled change from CHANGES --- CHANGES.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8824ece5a9..b9b3e9d0ea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,7 +17,6 @@ Webclient: * Only autoscroll on receiving a new message if the user was already at the bottom of the screen. * Add support for ban/kick reasons. - * Fix bug where we occaisonally saw duplicated join messages. Changes in synapse 0.2.1 (2014-09-03) ===================================== From ce5cd2202f7ed7807841fb617c8c63de1635da4f Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Sat, 6 Sep 2014 10:14:57 -0700 Subject: [PATCH 011/108] Center recaptcha dialog. --- webclient/app.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/app.css b/webclient/app.css index e0ca2f77a8..7698cb4fda 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -147,6 +147,10 @@ a:active { color: #000; } text-align: center; } +#recaptcha_area { + margin: auto +} + #loginForm { text-align: left; padding: 1em; From dc1f202eca5e58eae243f5a1214d1acda5cbccd5 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 6 Sep 2014 10:26:30 -0700 Subject: [PATCH 012/108] fix desktop notifs, which were broken in eab463fd --- webclient/components/matrix/presence-service.js | 2 +- webclient/room/room-controller.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webclient/components/matrix/presence-service.js b/webclient/components/matrix/presence-service.js index 952c8ec8a9..b487e3d3bd 100644 --- a/webclient/components/matrix/presence-service.js +++ b/webclient/components/matrix/presence-service.js @@ -24,7 +24,7 @@ angular.module('mPresence', []) .service('mPresence', ['$timeout', 'matrixService', function ($timeout, matrixService) { // Time in ms after that a user is considered as unavailable/away - var UNAVAILABLE_TIME = 5 * 60000; // 5 mins + var UNAVAILABLE_TIME = 3 * 60000; // 3 mins // The current presence state var state = undefined; diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index e69adb9b46..c702917fef 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -62,8 +62,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) scrollToBottom(); if (window.Notification) { - // Show notification when the user is idle - if (matrixService.presence.offline === mPresence.getState()) { + // Show notification when the window is hidden, or the user is idle + if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { var notification = new window.Notification( ($scope.members[event.user_id].displayname || event.user_id) + " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here From a0a609e8afad550be86bf47828315d727214026f Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 6 Sep 2014 17:48:16 -0700 Subject: [PATCH 013/108] fix embarassing bug where in-progress messages get vaped when the previous one gets delivered --- webclient/room/room-controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c702917fef..c8ca771b25 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -485,7 +485,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) promise.then( function() { console.log("Request successfully sent"); - $scope.textInput = ""; + if (!echo) { + $scope.textInput = ""; + } /* if (echoMessage) { // Remove the fake echo message from the room messages From 972f664b6b38029cda46a6ba709b7fc8b6b6bdae Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Sep 2014 16:10:36 +0100 Subject: [PATCH 014/108] add sounds to the calling interface --- webclient/app-controller.js | 27 +++++++++++- webclient/components/matrix/matrix-call.js | 46 +++++++++++++++------ webclient/index.html | 16 +++++++ webclient/media/busy.mp3 | Bin 0 -> 24834 bytes webclient/media/busy.ogg | Bin 0 -> 13960 bytes webclient/media/callend.mp3 | Bin 0 -> 12971 bytes webclient/media/callend.ogg | Bin 0 -> 13932 bytes webclient/media/ring.mp3 | Bin 0 -> 19662 bytes webclient/media/ring.ogg | Bin 0 -> 20636 bytes webclient/media/ringback.mp3 | Bin 0 -> 18398 bytes webclient/media/ringback.ogg | Bin 0 -> 8352 bytes 11 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 webclient/media/busy.mp3 create mode 100644 webclient/media/busy.ogg create mode 100644 webclient/media/callend.mp3 create mode 100644 webclient/media/callend.ogg create mode 100644 webclient/media/ring.mp3 create mode 100644 webclient/media/ring.ogg create mode 100644 webclient/media/ringback.mp3 create mode 100644 webclient/media/ringback.ogg diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 064bde3ab2..20b5076727 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -105,6 +105,29 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even } ); }); + $rootScope.$watch('currentCall.state', function(newVal, oldVal) { + if (newVal == 'ringing') { + angular.element('#ringbackAudio')[0].pause(); + angular.element('#ringAudio')[0].load(); + angular.element('#ringAudio')[0].play(); + } else if (newVal == 'invite_sent') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].load(); + angular.element('#ringbackAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'connected') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#callendAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'invite_sent') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#busyAudio')[0].play(); + } else if (oldVal == 'invite_sent') { + angular.element('#ringbackAudio')[0].pause(); + } else if (oldVal == 'ringing') { + angular.element('#ringAudio')[0].pause(); + } + }); $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) { console.trace("incoming call"); @@ -125,7 +148,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $animate.addClass(icon, 'callIconRotate'); $timeout(function(){ $rootScope.currentCall = undefined; - }, 2000); + }, 4070); }, 100); }; @@ -139,7 +162,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $animate.addClass(icon, 'callIconRotate'); $timeout(function(){ $rootScope.currentCall = undefined; - }, 2000); + }, 4070); }, 100); } }]); diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 3cb5e8b693..4eaed89bcf 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -120,7 +120,9 @@ angular.module('MatrixCall', []) }, function(e) { self.getLocalOfferFailed(e); }); - this.state = 'create_offer'; + $rootScope.$apply(function() { + self.state = 'create_offer'; + }); }; MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { @@ -138,7 +140,9 @@ angular.module('MatrixCall', []) }, }; this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints); - this.state = 'create_answer'; + $rootScope.$apply(function() { + self.state = 'create_answer'; + }); }; MatrixCall.prototype.gotLocalIceCandidate = function(event) { @@ -177,7 +181,11 @@ angular.module('MatrixCall', []) offer: description }; matrixService.sendEvent(this.room_id, 'm.call.invite', undefined, content).then(this.messageSent, this.messageSendFailed); - this.state = 'invite_sent'; + + self = this; + $rootScope.$apply(function() { + self.state = 'invite_sent'; + }); }; MatrixCall.prototype.createdAnswer = function(description) { @@ -189,7 +197,10 @@ angular.module('MatrixCall', []) answer: description }; matrixService.sendEvent(this.room_id, 'm.call.answer', undefined, content).then(this.messageSent, this.messageSendFailed); - this.state = 'connecting'; + self = this; + $rootScope.$apply(function() { + self.state = 'connecting'; + }); }; MatrixCall.prototype.messageSent = function() { @@ -211,9 +222,11 @@ angular.module('MatrixCall', []) console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState); // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') { - this.state = 'connected'; - this.didConnect = true; - $rootScope.$apply(); + self = this; + $rootScope.$apply(function() { + self.state = 'connected'; + self.didConnect = true; + }); } }; @@ -251,17 +264,26 @@ angular.module('MatrixCall', []) }; MatrixCall.prototype.onRemoteStreamStarted = function(event) { - this.state = 'connected'; + self = this; + $rootScope.$apply(function() { + self.state = 'connected'; + }); }; MatrixCall.prototype.onRemoteStreamEnded = function(event) { - this.state = 'ended'; - this.stopAllMedia(); - this.onHangup(); + self = this; + $rootScope.$apply(function() { + self.state = 'ended'; + self.stopAllMedia(); + self.onHangup(); + }); }; MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { - this.state = 'connected'; + self = this; + $rootScope.$apply(function() { + self.state = 'connected'; + }); }; MatrixCall.prototype.onHangupReceived = function() { diff --git a/webclient/index.html b/webclient/index.html index 81c7c7d06c..53ac1cb10e 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -70,6 +70,22 @@ + + + + {{ user_id }}   diff --git a/webclient/media/busy.mp3 b/webclient/media/busy.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fec27ba4c5952980bf43e83fc52203af300b02a2 GIT binary patch literal 24834 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 SilT;wre%NUE71uW2mTk^o?EH_ literal 0 HcmV?d00001 diff --git a/webclient/media/callend.mp3 b/webclient/media/callend.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..50c34e56401d25f33db5fdf41c1fb1d223ff6cdd GIT binary patch literal 12971 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 YUFWf)L3S_z;CWn-{-am?|3CTv0)`V=SpWb4 literal 0 HcmV?d00001 diff --git a/webclient/media/callend.ogg b/webclient/media/callend.ogg new file mode 100644 index 0000000000000000000000000000000000000000..927ce1f6340a6d9c65bcb03d244b6c3346060443 GIT binary patch literal 13932 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 LM6ir(Ko0y5xc4|N literal 0 HcmV?d00001 diff --git a/webclient/media/ring.mp3 b/webclient/media/ring.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3c3cdde3f9c5695063a0eaaabf1b995ee24702bf GIT binary patch literal 19662 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 literal 0 HcmV?d00001 From 21d6ce2380109c2854c7589702c22b553c2ca6ac Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 8 Sep 2014 18:13:22 +0200 Subject: [PATCH 015/108] App startup improvements: - do one and only one initialSync when the app starts. (recents-controller does not do its own anymore) - initialSync: get only the last message per room instead of default number of messages (10) Prevent recents-controller from loosing its data each time the page URL changes --- .../matrix/event-handler-service.js | 8 +-- .../components/matrix/event-stream-service.js | 4 +- webclient/recents/recents-controller.js | 66 +++++++++---------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index d2bb31053f..173055a61b 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -36,7 +36,7 @@ angular.module('eventHandlerService', []) var CALL_EVENT = "CALL_EVENT"; var NAME_EVENT = "NAME_EVENT"; - var InitialSyncDeferred = $q.defer(); + var initialSyncDeferred = $q.defer(); $rootScope.events = { rooms: {} // will contain roomId: { messages:[], members:{userid1: event} } @@ -220,14 +220,14 @@ angular.module('eventHandlerService', []) } }, - handleInitialSyncDone: function() { + handleInitialSyncDone: function(initialSyncData) { console.log("# handleInitialSyncDone"); - InitialSyncDeferred.resolve($rootScope.events, $rootScope.presence); + initialSyncDeferred.resolve(initialSyncData); }, // Returns a promise that resolves when the initialSync request has been processed waitForInitialSyncCompletion: function() { - return InitialSyncDeferred.promise; + return initialSyncDeferred.promise; }, resetRoomMessages: function(room_id) { diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index ed4f3b2ffc..28422c4cb9 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -105,7 +105,7 @@ angular.module('eventStreamService', []) var deferred = $q.defer(); // FIXME: We are discarding all the messages. - matrixService.rooms().then( + matrixService.rooms(1, false).then( function(response) { var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { @@ -120,7 +120,7 @@ angular.module('eventStreamService', []) eventHandlerService.handleEvents(presence, false); // Initial sync is done - eventHandlerService.handleInitialSyncDone(); + eventHandlerService.handleInitialSyncDone(response); settings.from = response.data.end; doEventStream(deferred); diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 0f27f7a660..45a671e631 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -17,62 +17,68 @@ 'use strict'; angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) -.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', - function($scope, matrixService, eventHandlerService) { - $scope.rooms = {}; +.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', + function($rootScope, $scope, matrixService, eventHandlerService) { + + // FIXME: Angularjs reloads the controller (and resets its $scope) each time + // the page URL changes, use $rootScope to avoid to have to reload data + $rootScope.rooms; - // $scope of the parent where the recents component is included can override this value + // $rootScope of the parent where the recents component is included can override this value // in order to highlight a specific room in the list - $scope.recentsSelectedRoomID; + $rootScope.recentsSelectedRoomID; var listenToEventStream = function() { // Refresh the list on matrix invitation and message event - $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { + $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { if (isLive) { - $scope.rooms[event.room_id].lastMsg = event; + $rootScope.rooms[event.room_id].lastMsg = event; } }); - $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { + $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { if (isLive) { - $scope.rooms[event.room_id].lastMsg = event; + $rootScope.rooms[event.room_id].lastMsg = event; } }); - $scope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { + $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { if (isLive) { - $scope.rooms[event.room_id].lastMsg = event; + $rootScope.rooms[event.room_id].lastMsg = event; } }); - $scope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) { + $rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) { if (isLive) { - $scope.rooms[event.room_id] = event; + $rootScope.rooms[event.room_id] = event; } }); }; - - var refresh = function() { - // List all rooms joined or been invited to - // TODO: This is a pity that event-stream-service.js makes the same call - // We should be able to reuse event-stream-service.js fetched data - matrixService.rooms(1, false).then( - function(response) { - // Reset data - $scope.rooms = {}; - var rooms = response.data.rooms; + $scope.onInit = function() { + // Init recents list only once + if ($rootScope.rooms) { + return; + } + + $rootScope.rooms = {}; + + // Use initialSync data to init the recents list + eventHandlerService.waitForInitialSyncCompletion().then( + function(initialSyncData) { + + var rooms = initialSyncData.data.rooms; for (var i=0; i Date: Mon, 8 Sep 2014 18:21:41 +0200 Subject: [PATCH 016/108] matrixService.rooms must be renamed matrixService.initialSync now --- webclient/components/matrix/event-stream-service.js | 2 +- webclient/components/matrix/matrix-service.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 28422c4cb9..4c0091dedb 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -105,7 +105,7 @@ angular.module('eventStreamService', []) var deferred = $q.defer(); // FIXME: We are discarding all the messages. - matrixService.rooms(1, false).then( + matrixService.initialSync(1, false).then( function(response) { var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 3c28c52fbe..6864726ba4 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -130,8 +130,9 @@ angular.module('matrixService', []) return doRequest("POST", path, undefined, req); }, - // List all rooms joined or been invited to - rooms: function(limit, feedback) { + // Get the user's current state: his presence, the list of his rooms with + // the last {limit} events + initialSync: function(limit, feedback) { // The REST path spec var path = "/initialSync"; From d81e7dc00ec1e1ddfe027e66a8061e64dfb074a5 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 8 Sep 2014 18:25:56 +0200 Subject: [PATCH 017/108] Added /join description --- webclient/settings/settings.html | 1 + 1 file changed, 1 insertion(+) diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html index 924812e7ae..c358a6e9d8 100644 --- a/webclient/settings/settings.html +++ b/webclient/settings/settings.html @@ -81,6 +81,7 @@
  • /nick <display_name>: change your display name
  • /me <action>: send the action you are doing. /me will be replaced by your display name
  • +
  • /join <room_alias>: join a room
  • /kick <user_id> [<reason>]: kick the user
  • /ban <user_id> [<reason>]: ban the user
  • /unban <user_id>: unban the user
  • From c0577ea87a19c169d68ed83760582fa1fabe36e5 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 18:34:18 +0100 Subject: [PATCH 018/108] Rollback if we try and insert duplicate events --- synapse/storage/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index 81c3c94b2e..8ed80109a5 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -179,6 +179,7 @@ class DataStore(RoomMemberStore, RoomStore, "Failed to persist, probably duplicate: %s", event.event_id ) + txn.rollback() return if not backfilled and hasattr(event, "state_key"): From 0627366b2fb38e0786d8ec225601d90c023a058b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 11:17:44 -0700 Subject: [PATCH 019/108] Sort the public room list by display name. --- webclient/home/home.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/home/home.html b/webclient/home/home.html index 7240e79f86..e3a49bb142 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -24,7 +24,7 @@

    Public rooms

    -
    +
    From de727f854a81c0a5610d82c4e173c13831836e9e Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 11:33:12 -0700 Subject: [PATCH 020/108] Make #matrix public rooms bold to make them stand out from the other public rooms. Ideally this would be metadata in /publicRooms to say something like 'featured channel', but for now, just make it a client side check. --- webclient/app.css | 4 ++++ webclient/home/home.html | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/webclient/app.css b/webclient/app.css index 7698cb4fda..0c6ae9b668 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -269,6 +269,10 @@ a:active { color: #000; } background-color: #faa; } +.roomHighlight { + font-weight: bold; +} + /*** Participant list ***/ #usersTableWrapper { diff --git a/webclient/home/home.html b/webclient/home/home.html index e3a49bb142..12b3c7f14e 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -26,7 +26,10 @@
    From 83ce57302dab6a825f3afde11926b5404ce1c9ff Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 19:50:46 +0100 Subject: [PATCH 021/108] Fix bug in state handling where we incorrectly identified a missing pdu. Update tests to catch this case. --- synapse/state.py | 100 +++++++++--------- synapse/storage/pdu.py | 9 +- tests/test_state.py | 233 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 271 insertions(+), 71 deletions(-) diff --git a/synapse/state.py b/synapse/state.py index 5dcff27367..e69282860a 100644 --- a/synapse/state.py +++ b/synapse/state.py @@ -134,7 +134,9 @@ class StateHandler(object): @defer.inlineCallbacks @log_function def _handle_new_state(self, new_pdu): - tree = yield self.store.get_unresolved_state_tree(new_pdu) + tree, missing_branch = yield self.store.get_unresolved_state_tree( + new_pdu + ) new_branch, current_branch = tree logger.debug( @@ -142,64 +144,17 @@ class StateHandler(object): new_branch, current_branch ) - if not current_branch: - # There is no current state - defer.returnValue(True) - return - - n = new_branch[-1] - c = current_branch[-1] - - if n.pdu_id == c.pdu_id and n.origin == c.origin: - # We have all the PDUs we need, so we can just do the conflict - # resolution. - - if len(current_branch) == 1: - # This is a direct clobber so we can just... - defer.returnValue(True) - - conflict_res = [ - self._do_power_level_conflict_res, - self._do_chain_length_conflict_res, - self._do_hash_conflict_res, - ] - - for algo in conflict_res: - new_res, curr_res = algo(new_branch, current_branch) - - if new_res < curr_res: - defer.returnValue(False) - elif new_res > curr_res: - defer.returnValue(True) - - raise Exception("Conflict resolution failed.") - - else: - # We need to ask for PDUs. - missing_prev = max( - new_branch[-1], current_branch[-1], - key=lambda x: x.depth - ) - - if not hasattr(missing_prev, "prev_state_id"): - # FIXME Hmm - # temporary fallback - for algo in conflict_res: - new_res, curr_res = algo(new_branch, current_branch) - - if new_res < curr_res: - defer.returnValue(False) - elif new_res > curr_res: - defer.returnValue(True) - return + if missing_branch is not None: + # We're missing some PDUs. Fetch them. + # TODO (erikj): Limit this. + missing_prev = tree[missing_branch][-1] pdu_id = missing_prev.prev_state_id origin = missing_prev.prev_state_origin is_missing = yield self.store.get_pdu(pdu_id, origin) is None - if not is_missing: - raise Exception("Conflict resolution failed.") + raise Exception("Conflict resolution failed") yield self._replication.get_pdu( destination=missing_prev.origin, @@ -211,6 +166,45 @@ class StateHandler(object): updated_current = yield self._handle_new_state(new_pdu) defer.returnValue(updated_current) + if not current_branch: + # There is no current state + defer.returnValue(True) + return + + n = new_branch[-1] + c = current_branch[-1] + + if n.pdu_id == c.pdu_id and n.origin == c.origin: + # We found a common ancestor! + + if len(current_branch) == 1: + # This is a direct clobber so we can just... + defer.returnValue(True) + + else: + # We didn't find a common ancestor. This is probably fine. + pass + + result = self._do_conflict_res(new_branch, current_branch) + defer.returnValue(result) + + def _do_conflict_res(self, new_branch, current_branch): + conflict_res = [ + self._do_power_level_conflict_res, + self._do_chain_length_conflict_res, + self._do_hash_conflict_res, + ] + + for algo in conflict_res: + new_res, curr_res = algo(new_branch, current_branch) + + if new_res < curr_res: + defer.returnValue(False) + elif new_res > curr_res: + defer.returnValue(True) + + raise Exception("Conflict resolution failed.") + def _do_power_level_conflict_res(self, new_branch, current_branch): max_power_new = max( new_branch[:-1], diff --git a/synapse/storage/pdu.py b/synapse/storage/pdu.py index 0bf97e37ee..3cbce2d0a1 100644 --- a/synapse/storage/pdu.py +++ b/synapse/storage/pdu.py @@ -308,8 +308,8 @@ class PduStore(SQLBaseStore): @defer.inlineCallbacks def get_oldest_pdus_in_context(self, context): - """Get a list of Pdus that we haven't backfilled beyond yet (and haven't - seen). This list is used when we want to backfill backwards and is the + """Get a list of Pdus that we haven't backfilled beyond yet (and havent + seen). This list is used when we want to backfill backwards and is the list we send to the remote server. Args: @@ -524,13 +524,16 @@ class StatePduStore(SQLBaseStore): txn, new_pdu, current ) + missing_branch = None for branch, prev_state, state in enum_branches: if state: return_value[branch].append(state) else: + # We don't have prev_state :( + missing_branch = branch break - return return_value + return (return_value, missing_branch) def update_current_state(self, pdu_id, origin, context, pdu_type, state_key): diff --git a/tests/test_state.py b/tests/test_state.py index b01496c40f..4512475ebd 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -24,6 +24,8 @@ from collections import namedtuple from mock import Mock +import mock + ReturnType = namedtuple( "StateReturnType", ["new_branch", "current_branch"] @@ -54,7 +56,7 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu], []) + (ReturnType([new_pdu], []), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -78,7 +80,7 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("B", "test", "mem", "x", "A", 5) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu], [old_pdu]) + (ReturnType([new_pdu, old_pdu], [old_pdu]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -103,7 +105,7 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 5) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -128,7 +130,7 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 15) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -153,7 +155,7 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 10) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]) + (ReturnType([new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1]), None) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -179,7 +181,13 @@ class StateTestCase(unittest.TestCase): new_pdu = new_fake_pdu_entry("D", "test", "mem", "x", "C", 10) self.persistence.get_unresolved_state_tree.return_value = ( - ReturnType([new_pdu, old_pdu_3, old_pdu_1], [old_pdu_2, old_pdu_1]) + ( + ReturnType( + [new_pdu, old_pdu_3, old_pdu_1], + [old_pdu_2, old_pdu_1] + ), + None + ) ) is_new = yield self.state.handle_new_state(new_pdu) @@ -200,22 +208,32 @@ class StateTestCase(unittest.TestCase): # triggering a get_pdu request # The pdu we haven't seen - old_pdu_1 = new_fake_pdu_entry("A", "test", "mem", "x", None, 10) + old_pdu_1 = new_fake_pdu_entry( + "A", "test", "mem", "x", None, 10, depth=0 + ) - old_pdu_2 = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) - new_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) + old_pdu_2 = new_fake_pdu_entry( + "B", "test", "mem", "x", "A", 10, depth=1 + ) + new_pdu = new_fake_pdu_entry( + "C", "test", "mem", "x", "A", 20, depth=2 + ) # The return_value of `get_unresolved_state_tree`, which changes after # the call to get_pdu - tree_to_return = [ReturnType([new_pdu], [old_pdu_2])] + tree_to_return = [(ReturnType([new_pdu], [old_pdu_2]), 0)] def return_tree(p): return tree_to_return[0] - def set_return_tree(*args, **kwargs): - tree_to_return[0] = ReturnType( - [new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1] + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + tree_to_return[0] = ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_2, old_pdu_1] + ), + None ) + return defer.succeed(None) self.persistence.get_unresolved_state_tree.side_effect = return_tree @@ -227,6 +245,13 @@ class StateTestCase(unittest.TestCase): self.assertTrue(is_new) + self.replication.get_pdu.assert_called_with( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ) + self.persistence.get_unresolved_state_tree.assert_called_with( new_pdu ) @@ -237,6 +262,184 @@ class StateTestCase(unittest.TestCase): self.assertEqual(1, self.persistence.update_current_state.call_count) + @defer.inlineCallbacks + def test_missing_pdu_depth_1(self): + # We try to update state against a PDU we haven't yet seen, + # triggering a get_pdu request + + # The pdu we haven't seen + old_pdu_1 = new_fake_pdu_entry( + "A", "test", "mem", "x", None, 10, depth=0 + ) + + old_pdu_2 = new_fake_pdu_entry( + "B", "test", "mem", "x", "A", 10, depth=2 + ) + old_pdu_3 = new_fake_pdu_entry( + "C", "test", "mem", "x", "B", 10, depth=3 + ) + new_pdu = new_fake_pdu_entry( + "D", "test", "mem", "x", "A", 20, depth=4 + ) + + # The return_value of `get_unresolved_state_tree`, which changes after + # the call to get_pdu + tree_to_return = [ + ( + ReturnType([new_pdu], [old_pdu_3]), + 0 + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3] + ), + 1 + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3, old_pdu_2, old_pdu_1] + ), + None + ), + ] + + to_return = [0] + + def return_tree(p): + return tree_to_return[to_return[0]] + + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + to_return[0] += 1 + return defer.succeed(None) + + self.persistence.get_unresolved_state_tree.side_effect = return_tree + + self.replication.get_pdu.side_effect = set_return_tree + + self.persistence.get_pdu.return_value = None + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.assertEqual(2, self.replication.get_pdu.call_count) + + self.replication.get_pdu.assert_has_calls( + [ + mock.call( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ), + mock.call( + destination=old_pdu_3.origin, + pdu_origin=old_pdu_2.origin, + pdu_id=old_pdu_2.pdu_id, + outlier=True + ), + ] + ) + + self.persistence.get_unresolved_state_tree.assert_called_with( + new_pdu + ) + + self.assertEquals( + 3, self.persistence.get_unresolved_state_tree.call_count + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + @defer.inlineCallbacks + def test_missing_pdu_depth_2(self): + # We try to update state against a PDU we haven't yet seen, + # triggering a get_pdu request + + # The pdu we haven't seen + old_pdu_1 = new_fake_pdu_entry( + "A", "test", "mem", "x", None, 10, depth=0 + ) + + old_pdu_2 = new_fake_pdu_entry( + "B", "test", "mem", "x", "A", 10, depth=2 + ) + old_pdu_3 = new_fake_pdu_entry( + "C", "test", "mem", "x", "B", 10, depth=3 + ) + new_pdu = new_fake_pdu_entry( + "D", "test", "mem", "x", "A", 20, depth=1 + ) + + # The return_value of `get_unresolved_state_tree`, which changes after + # the call to get_pdu + tree_to_return = [ + ( + ReturnType([new_pdu], [old_pdu_3]), + 1, + ), + ( + ReturnType( + [new_pdu], [old_pdu_3, old_pdu_2] + ), + 0, + ), + ( + ReturnType( + [new_pdu, old_pdu_1], [old_pdu_3, old_pdu_2, old_pdu_1] + ), + None + ), + ] + + to_return = [0] + + def return_tree(p): + return tree_to_return[to_return[0]] + + def set_return_tree(destination, pdu_origin, pdu_id, outlier=False): + to_return[0] += 1 + return defer.succeed(None) + + self.persistence.get_unresolved_state_tree.side_effect = return_tree + + self.replication.get_pdu.side_effect = set_return_tree + + self.persistence.get_pdu.return_value = None + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.assertEqual(2, self.replication.get_pdu.call_count) + + self.replication.get_pdu.assert_has_calls( + [ + mock.call( + destination=old_pdu_3.origin, + pdu_origin=old_pdu_2.origin, + pdu_id=old_pdu_2.pdu_id, + outlier=True + ), + mock.call( + destination=new_pdu.origin, + pdu_origin=old_pdu_1.origin, + pdu_id=old_pdu_1.pdu_id, + outlier=True + ), + ] + ) + + self.persistence.get_unresolved_state_tree.assert_called_with( + new_pdu + ) + + self.assertEquals( + 3, self.persistence.get_unresolved_state_tree.call_count + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + @defer.inlineCallbacks def test_new_event(self): event = Mock() @@ -270,7 +473,7 @@ class StateTestCase(unittest.TestCase): def new_fake_pdu_entry(pdu_id, context, pdu_type, state_key, prev_state_id, - power_level): + power_level, depth=0): new_pdu = PduEntry( pdu_id=pdu_id, pdu_type=pdu_type, @@ -280,7 +483,7 @@ def new_fake_pdu_entry(pdu_id, context, pdu_type, state_key, prev_state_id, origin="example.com", context="context", ts=1405353060021, - depth=0, + depth=depth, content_json="{}", unrecognized_keys="{}", outlier=True, From 2eaa199e6ad2742fbfea54f7d6584bd5c8ac005a Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 11:55:19 -0700 Subject: [PATCH 022/108] Added number of users in recent rooms. --- webclient/recents/recents-controller.js | 13 +++++++++++++ webclient/recents/recents.html | 3 +++ 2 files changed, 16 insertions(+) diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 45a671e631..fcb203b36c 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -76,12 +76,25 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand if (room.messages && room.messages.chunk && room.messages.chunk[0]) { $rootScope.rooms[room.room_id].lastMsg = room.messages.chunk[0]; } + + + var numUsersInRoom = 0; + if (room.state) { + for (var j=0; j {{ room.room_id | mRoomName }} + + {{ room.numUsersInRoom }} users + {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} From 942d8412c49a1d481f0bedd189eb1598629b103c Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 20:13:27 +0100 Subject: [PATCH 023/108] Handle the case where we don't have a common ancestor --- synapse/state.py | 27 ++++++++++++++++++--------- tests/test_state.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/synapse/state.py b/synapse/state.py index e69282860a..0cc1344d51 100644 --- a/synapse/state.py +++ b/synapse/state.py @@ -174,7 +174,9 @@ class StateHandler(object): n = new_branch[-1] c = current_branch[-1] - if n.pdu_id == c.pdu_id and n.origin == c.origin: + common_ancestor = n.pdu_id == c.pdu_id and n.origin == c.origin + + if common_ancestor: # We found a common ancestor! if len(current_branch) == 1: @@ -185,10 +187,12 @@ class StateHandler(object): # We didn't find a common ancestor. This is probably fine. pass - result = self._do_conflict_res(new_branch, current_branch) + result = self._do_conflict_res( + new_branch, current_branch, common_ancestor + ) defer.returnValue(result) - def _do_conflict_res(self, new_branch, current_branch): + def _do_conflict_res(self, new_branch, current_branch, common_ancestor): conflict_res = [ self._do_power_level_conflict_res, self._do_chain_length_conflict_res, @@ -196,7 +200,9 @@ class StateHandler(object): ] for algo in conflict_res: - new_res, curr_res = algo(new_branch, current_branch) + new_res, curr_res = algo( + new_branch, current_branch, common_ancestor + ) if new_res < curr_res: defer.returnValue(False) @@ -205,23 +211,26 @@ class StateHandler(object): raise Exception("Conflict resolution failed.") - def _do_power_level_conflict_res(self, new_branch, current_branch): + def _do_power_level_conflict_res(self, new_branch, current_branch, + common_ancestor): max_power_new = max( - new_branch[:-1], + new_branch[:-1] if common_ancestor else new_branch, key=lambda t: t.power_level ).power_level max_power_current = max( - current_branch[:-1], + current_branch[:-1] if common_ancestor else current_branch, key=lambda t: t.power_level ).power_level return (max_power_new, max_power_current) - def _do_chain_length_conflict_res(self, new_branch, current_branch): + def _do_chain_length_conflict_res(self, new_branch, current_branch, + common_ancestor): return (len(new_branch), len(current_branch)) - def _do_hash_conflict_res(self, new_branch, current_branch): + def _do_hash_conflict_res(self, new_branch, current_branch, + common_ancestor): new_str = "".join([p.pdu_id + p.origin for p in new_branch]) c_str = "".join([p.pdu_id + p.origin for p in current_branch]) diff --git a/tests/test_state.py b/tests/test_state.py index 4512475ebd..a9fc3fb85c 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -440,6 +440,30 @@ class StateTestCase(unittest.TestCase): self.assertEqual(1, self.persistence.update_current_state.call_count) + @defer.inlineCallbacks + def test_no_common_ancestor(self): + # We do a direct overwriting of the old state, i.e., the new state + # points to the old state. + + old_pdu = new_fake_pdu_entry("A", "test", "mem", "x", None, 5) + new_pdu = new_fake_pdu_entry("B", "test", "mem", "x", None, 10) + + self.persistence.get_unresolved_state_tree.return_value = ( + (ReturnType([new_pdu], [old_pdu]), None) + ) + + is_new = yield self.state.handle_new_state(new_pdu) + + self.assertTrue(is_new) + + self.persistence.get_unresolved_state_tree.assert_called_once_with( + new_pdu + ) + + self.assertEqual(1, self.persistence.update_current_state.call_count) + + self.assertFalse(self.replication.get_pdu.called) + @defer.inlineCallbacks def test_new_event(self): event = Mock() From 76fe7d4eba334cee8b5c18ac26da709106dff1a2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 12:11:36 -0700 Subject: [PATCH 024/108] Added num_joined_users key to /publicRooms for each room. Show this information in the webclient. --- synapse/handlers/room.py | 6 ++++++ webclient/app.css | 4 ++++ webclient/home/home.html | 5 ++++- webclient/recents/recents.html | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index a0d0f2af16..310cb46fe7 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -593,6 +593,12 @@ class RoomListHandler(BaseHandler): @defer.inlineCallbacks def get_public_room_list(self): chunk = yield self.store.get_rooms(is_public=True) + for room in chunk: + joined_members = yield self.store.get_room_members( + room_id=room["room_id"], + membership=Membership.JOIN + ) + room["num_joined_members"] = len(joined_members) # FIXME (erikj): START is no longer a valid value defer.returnValue({"start": "START", "end": "END", "chunk": chunk}) diff --git a/webclient/app.css b/webclient/app.css index 0c6ae9b668..b438cf0405 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -273,6 +273,10 @@ a:active { color: #000; } font-weight: bold; } +.publicRoomEntry { + margin-bottom: 5px; +} + /*** Participant list ***/ #usersTableWrapper { diff --git a/webclient/home/home.html b/webclient/home/home.html index 12b3c7f14e..cf6771814c 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -25,11 +25,14 @@

    Public rooms

    -
    +
    {{ room.room_display_name }} +
    + {{ room.num_joined_members }} {{ room.num_joined_members == 1 ? 'user' : 'users' }} +

    diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index b903412815..efc5c39689 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -9,7 +9,7 @@ {{ room.room_id | mRoomName }} - {{ room.numUsersInRoom }} users + {{ room.numUsersInRoom }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} From e0954f3b365c3f9f99ab8d91fc18ff933d836e72 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 12:15:20 -0700 Subject: [PATCH 025/108] Better checks are better. --- webclient/home/home.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/home/home.html b/webclient/home/home.html index cf6771814c..f3abd76a05 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -30,7 +30,7 @@ ng-class="room.room_display_name.toLowerCase().indexOf('#matrix:') === 0 ? 'roomHighlight' : ''"> {{ room.room_display_name }} -
    +
    {{ room.num_joined_members }} {{ room.num_joined_members == 1 ? 'user' : 'users' }}
    From 054fad5360a925635fc392e5e6fa853f9b034e39 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 13:28:55 -0700 Subject: [PATCH 026/108] Float right the num users, apply room highlight to user count. --- webclient/app.css | 5 +++++ webclient/home/home.html | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/webclient/app.css b/webclient/app.css index b438cf0405..19fae632ff 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -274,9 +274,14 @@ a:active { color: #000; } } .publicRoomEntry { + max-width: 480px; margin-bottom: 5px; } +.publicRoomJoinedUsers { + float: right; +} + /*** Participant list ***/ #usersTableWrapper { diff --git a/webclient/home/home.html b/webclient/home/home.html index f3abd76a05..6d599e6849 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -30,7 +30,8 @@ ng-class="room.room_display_name.toLowerCase().indexOf('#matrix:') === 0 ? 'roomHighlight' : ''"> {{ room.room_display_name }} -
    +
    {{ room.num_joined_members }} {{ room.num_joined_members == 1 ? 'user' : 'users' }}
    From da9b7b03688e40c0ed5b14ab0e33fb77b6d8b931 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 13:54:09 -0700 Subject: [PATCH 027/108] Added big massive TODOs on a huge design problem with initial sync --- .../components/matrix/event-stream-service.js | 2 ++ webclient/recents/recents-controller.js | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 4c0091dedb..1bc850a8fa 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -105,6 +105,8 @@ angular.module('eventStreamService', []) var deferred = $q.defer(); // FIXME: We are discarding all the messages. + // XXX FIXME TODO : The discard works because we are doing this all over + // again on EVERY INSTANTIATION of the recents controller. matrixService.initialSync(1, false).then( function(response) { var rooms = response.data.rooms; diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index fcb203b36c..b4762acd1d 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -16,6 +16,12 @@ 'use strict'; +// XXX FIXME TODO +// We should NOT be dumping things into $rootScope!!!! We should NOT be +// making any requests here, and should READ what is already in the +// rootScope from the event handler service!!! +// XXX FIXME TODO + angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) .controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', function($rootScope, $scope, matrixService, eventHandlerService) { @@ -28,6 +34,11 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand // in order to highlight a specific room in the list $rootScope.recentsSelectedRoomID; + // XXX FIXME TODO : We should NOT be doing this here, which could be + // repeated for every controller instance. We should be doing this in + // event handler service instead. In additon, this will break if there + // isn't a recents controller visible when the last message comes in :/ + var listenToEventStream = function() { // Refresh the list on matrix invitation and message event $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { @@ -58,7 +69,13 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand if ($rootScope.rooms) { return; } - + + // XXX FIXME TODO + // We should NOT be dumping things into $rootScope!!!! We should NOT be + // making any requests here, and should READ what is already in the + // rootScope from the event handler service!!! + // XXX FIXME TODO + $rootScope.rooms = {}; // Use initialSync data to init the recents list From a3590dfa262d0113a354591676942e05c20b5cbf Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 14:01:34 -0700 Subject: [PATCH 028/108] Bodge to default to '1 users' when you create a room, which is better than blindly assuming a recents controller is writing to rootScope.rooms and setting numUsersInRoom there. --- webclient/recents/recents-controller.js | 5 +++++ webclient/recents/recents.html | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index b4762acd1d..701c935742 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -82,6 +82,11 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand eventHandlerService.waitForInitialSyncCompletion().then( function(initialSyncData) { + // XXX FIXME TODO: + // Any assignments to the rootScope here should be done in + // event handler service and not here, because we could have + // many controllers manipulating and clobbering each other, and + // are unecessarily repeating http requests. var rooms = initialSyncData.data.rooms; for (var i=0; i - {{ room.numUsersInRoom }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} + {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} From d692994ea4566abddf27338561922608a117b4c0 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 14:16:22 -0700 Subject: [PATCH 029/108] Updated jsfiddle links to point to github --- docs/client-server/howto.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/client-server/howto.rst b/docs/client-server/howto.rst index c02ea8d897..ec941edddd 100644 --- a/docs/client-server/howto.rst +++ b/docs/client-server/howto.rst @@ -24,7 +24,7 @@ If you already have an account, you must **login** into it. `Try out the fiddle`__ -.. __: http://jsfiddle.net/4q2jyxng/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/register_login Registration ------------ @@ -87,7 +87,7 @@ user and **send a message** to that room. `Try out the fiddle`__ -.. __: http://jsfiddle.net/zL3zto9g/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/create_room_send_msg Creating a room --------------- @@ -137,7 +137,7 @@ join a room **via a room alias** if one was set up. `Try out the fiddle`__ -.. __: http://jsfiddle.net/7fhotf1b/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/room_memberships Inviting a user to a room ------------------------- @@ -183,7 +183,7 @@ of getting events, depending on what the client already knows. `Try out the fiddle`__ -.. __: http://jsfiddle.net/vw11mg37/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/event_stream Getting all state ----------------- @@ -633,4 +633,4 @@ application. `Try out the fiddle`__ -.. __: http://jsfiddle.net/uztL3yme/ +.. __: http://jsfiddle.net/gh/get/jquery/1.8.3/matrix-org/synapse/tree/master/jsfiddles/example_app From c1a25756c2c66e61f398472db9e3693a72911cfd Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 14:24:28 -0700 Subject: [PATCH 030/108] Added demo.details --- jsfiddles/example_app/demo.details | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 jsfiddles/example_app/demo.details diff --git a/jsfiddles/example_app/demo.details b/jsfiddles/example_app/demo.details new file mode 100644 index 0000000000..3f96d3e744 --- /dev/null +++ b/jsfiddles/example_app/demo.details @@ -0,0 +1,7 @@ + name: Example Matrix Client + description: Includes login, live event streaming, creating rooms, sending messages and viewing member lists. + authors: + - matrix.org + resources: + - http://matrix.org + normalize_css: no \ No newline at end of file From e062f2dfa89eca20d409642b61bb240accb51bf1 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 22:36:51 +0100 Subject: [PATCH 031/108] Apparently we can't do txn.rollback(), so raise and catch an exception instead. --- synapse/storage/__init__.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index 8ed80109a5..a2eec3b209 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -47,6 +47,11 @@ import os logger = logging.getLogger(__name__) +class _RollbackButIsFineException(Exception): + """ This exception is used to rollback a transaction without implying + something went wrong. + """ + pass class DataStore(RoomMemberStore, RoomStore, RegistrationStore, StreamStore, ProfileStore, FeedbackStore, @@ -71,13 +76,16 @@ class DataStore(RoomMemberStore, RoomStore, self.min_token -= 1 stream_ordering = self.min_token - latest = yield self._db_pool.runInteraction( - self._persist_pdu_event_txn, - pdu=pdu, - event=event, - backfilled=backfilled, - stream_ordering=stream_ordering, - ) + try: + latest = yield self._db_pool.runInteraction( + self._persist_pdu_event_txn, + pdu=pdu, + event=event, + backfilled=backfilled, + stream_ordering=stream_ordering, + ) + except _RollbackButIsFineException as e: + pass defer.returnValue(latest) @defer.inlineCallbacks @@ -175,12 +183,12 @@ class DataStore(RoomMemberStore, RoomStore, try: self._simple_insert_txn(txn, "events", vals) except: - logger.exception( + logger.warn( "Failed to persist, probably duplicate: %s", - event.event_id + event.event_id, + exc_info=True, ) - txn.rollback() - return + raise _RollbackButIsFineException("_persist_event") if not backfilled and hasattr(event, "state_key"): vals = { From 91b370650ab288de6cf22765b43d9ffb7907beb2 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 22:48:40 +0100 Subject: [PATCH 032/108] Don't autogen config in synctl for the same reasons we don't turn of --generate-config by default on the homeserver - it is liable to confuse people who have moved the config file or have chosen a non standard location. Also, don't override log file location. --- synctl | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/synctl b/synctl index 99dc545ba5..ff9f6893e9 100755 --- a/synctl +++ b/synctl @@ -4,7 +4,6 @@ SYNAPSE="synapse/app/homeserver.py" CONFIGFILE="homeserver.yaml" PIDFILE="homeserver.pid" -LOGFILE="homeserver.log" GREEN=$'\e[1;32m' NORMAL=$'\e[m' @@ -14,15 +13,12 @@ set -e case "$1" in start) if [ ! -f "$CONFIGFILE" ]; then - echo "No config file found - generating a default one..." - $SYNAPSE -c "$CONFIGFILE" --generate-config - echo "Wrote $CONFIGFILE" - echo "You must now edit this file before continuing" + echo "No config file found" exit 1 fi echo -n "Starting ..." - $SYNAPSE --daemonize -c "$CONFIGFILE" --pid-file "$PIDFILE" --log-file "$LOGFILE" + $SYNAPSE --daemonize -c "$CONFIGFILE" --pid-file "$PIDFILE" echo "${GREEN}started${NORMAL}" ;; stop) From 5236de5b03aa97cde1bec9c5d32d17beb0bffbc6 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 8 Sep 2014 22:52:50 +0100 Subject: [PATCH 033/108] Add slightly helpful advice on how to generate config if you don'y already have one --- synctl | 1 + 1 file changed, 1 insertion(+) diff --git a/synctl b/synctl index ff9f6893e9..0f83e9cb1f 100755 --- a/synctl +++ b/synctl @@ -14,6 +14,7 @@ case "$1" in start) if [ ! -f "$CONFIGFILE" ]; then echo "No config file found" + echo "To generate a config file, run 'python --generate-config'" exit 1 fi From 544691ab05e7b5e5a265e7fda2aab0fae3a83097 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 14:53:58 -0700 Subject: [PATCH 034/108] Update jsfiddles to have more helpful error messages when there is no connection when logging in. --- jsfiddles/create_room_send_msg/demo.js | 7 ++++++- jsfiddles/event_stream/demo.js | 7 ++++++- jsfiddles/register_login/demo.js | 14 ++++++++++++-- jsfiddles/room_memberships/demo.js | 7 ++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/jsfiddles/create_room_send_msg/demo.js b/jsfiddles/create_room_send_msg/demo.js index 3dc7263830..9c346e2f64 100644 --- a/jsfiddles/create_room_send_msg/demo.js +++ b/jsfiddles/create_room_send_msg/demo.js @@ -19,7 +19,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); diff --git a/jsfiddles/event_stream/demo.js b/jsfiddles/event_stream/demo.js index 5c81e08caa..acba8391fa 100644 --- a/jsfiddles/event_stream/demo.js +++ b/jsfiddles/event_stream/demo.js @@ -58,7 +58,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); diff --git a/jsfiddles/register_login/demo.js b/jsfiddles/register_login/demo.js index 9595039173..fffa9e0551 100644 --- a/jsfiddles/register_login/demo.js +++ b/jsfiddles/register_login/demo.js @@ -20,7 +20,12 @@ $('.register').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); @@ -36,7 +41,12 @@ var login = function(user, password) { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }; diff --git a/jsfiddles/room_memberships/demo.js b/jsfiddles/room_memberships/demo.js index 64ba767138..8a7b1aa88e 100644 --- a/jsfiddles/room_memberships/demo.js +++ b/jsfiddles/room_memberships/demo.js @@ -28,7 +28,12 @@ $('.login').live('click', function() { showLoggedIn(data); }, error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); + var errMsg = "To try this, you need a home server running!"; + var errJson = $.parseJSON(err.responseText); + if (errJson) { + errMsg = JSON.stringify(errJson); + } + alert(errMsg); } }); }); From 324020d5fe212badfbd38137adc8dcecfdc15980 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 15:36:52 -0700 Subject: [PATCH 035/108] Display the room topic in the room, underneath the name of the room. --- webclient/app.css | 21 +++++++++++++------ .../matrix/event-handler-service.js | 11 ++++++++++ webclient/room/room.html | 9 ++++++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/webclient/app.css b/webclient/app.css index 19fae632ff..7c367df421 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -220,12 +220,6 @@ a:active { color: #000; } height: 100%; } -#roomName { - float: right; - font-size: 16px; - margin-top: 15px; -} - #roomHeader { margin: auto; padding-left: 20px; @@ -282,6 +276,21 @@ a:active { color: #000; } float: right; } +#roomName { + font-size: 16px; + text-align: right; +} + +#roomTopic { + text-align: right; + font-size: 13px; +} + +.roomHeaderInfo { + float: right; + margin-top: 15px; +} + /*** Participant list ***/ #usersTableWrapper { diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 173055a61b..a14e515999 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -148,6 +148,14 @@ angular.module('eventHandlerService', []) $rootScope.events.rooms[event.room_id][event.type] = event; $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent); }; + + var handleRoomTopic = function(event, isLiveEvent) { + console.log("handleRoomTopic " + isLiveEvent); + + initRoom(event.room_id); + + $rootScope.events.rooms[event.room_id][event.type] = event; + }; var handleCallEvent = function(event, isLiveEvent) { $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent); @@ -204,6 +212,9 @@ angular.module('eventHandlerService', []) case 'm.room.name': handleRoomName(event, isLiveEvent); break; + case 'm.room.topic': + handleRoomTopic(event, isLiveEvent); + break; default: console.log("Unable to handle event type " + event.type); console.log(JSON.stringify(event, undefined, 4)); diff --git a/webclient/room/room.html b/webclient/room/room.html index 5bd2cc92d5..4be2482f96 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -2,8 +2,13 @@
    [matrix] -
    - {{ room_id | mRoomName }} +
    +
    + {{ room_id | mRoomName }} +
    +
    + {{ events.rooms[room_id]['m.room.topic'].content.topic }} +
    From df50a6823fdc60cdb7cb3a6497b1067cca63fc33 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 17:14:58 -0700 Subject: [PATCH 036/108] Display public room topics if they exist on the public room list. --- webclient/app.css | 7 +++++++ webclient/home/home.html | 3 +++ 2 files changed, 10 insertions(+) diff --git a/webclient/app.css b/webclient/app.css index 7c367df421..ab3d815e00 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -276,6 +276,13 @@ a:active { color: #000; } float: right; } +.publicRoomTopic { + color: #888; + font-size: 12px; + padding-right: 5px; + float: right; +} + #roomName { font-size: 16px; text-align: right; diff --git a/webclient/home/home.html b/webclient/home/home.html index 6d599e6849..023ae1a40c 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -34,6 +34,9 @@ ng-show="room.num_joined_members"> {{ room.num_joined_members }} {{ room.num_joined_members == 1 ? 'user' : 'users' }}
    +
    + {{ room.topic }} +

    From ef2111099abf5cf6b187927c48c8575966706ee2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 17:19:04 -0700 Subject: [PATCH 037/108] long topic is long. CSS support it --- webclient/app.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/app.css b/webclient/app.css index ab3d815e00..2f969641b4 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -281,6 +281,10 @@ a:active { color: #000; } font-size: 12px; padding-right: 5px; float: right; + width: 15em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } #roomName { From f64cc237fc819116e78888fb1c542b1c8c08651a Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 17:27:51 -0700 Subject: [PATCH 038/108] Fixed bug which displayed an older room topic because it was being returned from /initialSync messages key. Check the ts of the event before clobbering state. --- .../components/matrix/event-handler-service.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index a14e515999..8232e3b4b0 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -150,10 +150,22 @@ angular.module('eventHandlerService', []) }; var handleRoomTopic = function(event, isLiveEvent) { - console.log("handleRoomTopic " + isLiveEvent); + console.log("handleRoomTopic live="+isLiveEvent); initRoom(event.room_id); + // live events always update, but non-live events only update if the + // ts is later. + if (!isLiveEvent) { + var eventTs = event.ts; + var storedEvent = $rootScope.events.rooms[event.room_id][event.type]; + if (storedEvent) { + if (storedEvent.ts > eventTs) { + // ignore it, we have a newer one already. + return; + } + } + } $rootScope.events.rooms[event.room_id][event.type] = event; }; From 6bdb23449a7b4f5ca0426ec6c942332b245eec30 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 18:40:34 -0700 Subject: [PATCH 039/108] Add ability to set topic by double-clicking on the topic text then hitting enter. --- webclient/app.css | 5 ++++ .../matrix/event-handler-service.js | 1 + webclient/components/matrix/matrix-service.js | 19 ++++++++++++++ webclient/room/room-controller.js | 25 +++++++++++++++++++ webclient/room/room.html | 10 +++++++- 5 files changed, 59 insertions(+), 1 deletion(-) diff --git a/webclient/app.css b/webclient/app.css index 2f969641b4..9667f3fd22 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -297,9 +297,14 @@ a:active { color: #000; } font-size: 13px; } +.roomTopicInput { + width: 100%; +} + .roomHeaderInfo { float: right; margin-top: 15px; + width: 50%; } /*** Participant list ***/ diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 8232e3b4b0..5a3e92186e 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -149,6 +149,7 @@ angular.module('eventHandlerService', []) $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent); }; + // TODO: Can this just be a generic "I am a room state event, can haz store?" var handleRoomTopic = function(event, isLiveEvent) { console.log("handleRoomTopic live="+isLiveEvent); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 6864726ba4..62aff091d4 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -235,6 +235,25 @@ angular.module('matrixService', []) return doRequest("GET", path, undefined, {}); }, + + setTopic: function(room_id, topic) { + var data = { + topic: topic + }; + return this.sendStateEvent(room_id, "m.room.topic", data); + }, + + + sendStateEvent: function(room_id, eventType, content, state_key) { + var path = "/rooms/$room_id/state/"+eventType; + if (state_key !== undefined) { + path += "/" + state_key; + } + room_id = encodeURIComponent(room_id); + path = path.replace("$room_id", room_id); + + return doRequest("PUT", path, undefined, content); + }, sendEvent: function(room_id, eventType, txn_id, content) { // The REST path spec diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c8ca771b25..10ff12a96b 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -42,6 +42,31 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.imageURLToSend = ""; $scope.userIDToInvite = ""; + // vars and functions for updating the topic + $scope.topic = { + isEditing: false, + newTopicText: "", + editTopic: function() { + if ($scope.topic.isEditing) { + console.log("Warning: Already editing topic."); + return; + } + $scope.topic.newTopicText = $rootScope.events.rooms[$scope.room_id]['m.room.topic'].content.topic; + $scope.topic.isEditing = true; + }, + updateTopic: function() { + console.log("Updating topic to "+$scope.topic.newTopicText); + matrixService.setTopic($scope.room_id, $scope.topic.newTopicText); + $scope.topic.isEditing = false; + }, + cancelEdit: function() { + $scope.topic.isEditing = false; + } + }; + + + + var scrollToBottom = function(force) { console.log("Scrolling to bottom"); diff --git a/webclient/room/room.html b/webclient/room/room.html index 4be2482f96..0fe45499e0 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -7,7 +7,15 @@ {{ room_id | mRoomName }}
    - {{ events.rooms[room_id]['m.room.topic'].content.topic }} +
    + {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}} +
    + +
    + +
    +
    From e8f19b4c0d774bd6f1942aa4557ad0be728b9a4f Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 18:59:26 -0700 Subject: [PATCH 040/108] Display a 'Set Topic' button if there is no topic or it's a 0-len string. --- webclient/app.css | 10 +++++++++- webclient/room/room-controller.js | 9 ++++++++- webclient/room/room.html | 22 +++++++++++++--------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/webclient/app.css b/webclient/app.css index 9667f3fd22..0160b4deef 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -280,7 +280,6 @@ a:active { color: #000; } color: #888; font-size: 12px; padding-right: 5px; - float: right; width: 15em; overflow: hidden; white-space: nowrap; @@ -301,6 +300,15 @@ a:active { color: #000; } width: 100%; } +.roomTopicSection { + float: right; + width: 100%; +} + +.roomTopicSetNew { + float: right; +} + .roomHeaderInfo { float: right; margin-top: 15px; diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 10ff12a96b..68845df7d1 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -51,7 +51,14 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) console.log("Warning: Already editing topic."); return; } - $scope.topic.newTopicText = $rootScope.events.rooms[$scope.room_id]['m.room.topic'].content.topic; + var topicEvent = $rootScope.events.rooms[$scope.room_id]['m.room.topic']; + if (topicEvent) { + $scope.topic.newTopicText = topicEvent.content.topic; + } + else { + $scope.topic.newTopicText = ""; + } + $scope.topic.isEditing = true; }, updateTopic: function() { diff --git a/webclient/room/room.html b/webclient/room/room.html index 0fe45499e0..01f0c4ee33 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -6,16 +6,20 @@
    {{ room_id | mRoomName }}
    -
    -
    - {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}} +
    + +
    +
    + {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}} +
    +
    + +
    - -
    - -
    -
    From 75890d7bdd181edbc1fba50d9f756d18fa1dfd50 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 19:02:23 -0700 Subject: [PATCH 041/108] CSS tweakage --- webclient/app.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webclient/app.css b/webclient/app.css index 0160b4deef..634fa6bcc1 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -270,6 +270,7 @@ a:active { color: #000; } .publicRoomEntry { max-width: 480px; margin-bottom: 5px; + border-bottom: 1px #ddd solid; } .publicRoomJoinedUsers { @@ -281,6 +282,8 @@ a:active { color: #000; } font-size: 12px; padding-right: 5px; width: 15em; + float: right; + text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; From 16b40cbede292ae0faa073d18b5ff2175a531744 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 11:45:36 +0100 Subject: [PATCH 042/108] Show call invites in the message table --- webclient/components/matrix/event-handler-service.js | 3 +++ webclient/room/room.html | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 5a3e92186e..94ac91db5e 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -172,6 +172,9 @@ angular.module('eventHandlerService', []) var handleCallEvent = function(event, isLiveEvent) { $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent); + if (event.type == 'm.call.invite') { + $rootScope.events.rooms[event.room_id].messages.push(event); + } }; return { diff --git a/webclient/room/room.html b/webclient/room/room.html index 01f0c4ee33..5debeaba7c 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -97,6 +97,10 @@ + + Outgoing Call + Incoming Call +
    From 967ac65586b32543c77c68ba9d2e043d061ac549 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 10:46:15 +0200 Subject: [PATCH 043/108] BF: Made the grey background of the current room cover all the cell width --- webclient/recents/recents.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index 4972e401f6..4726b61cb3 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -17,7 +17,7 @@ - +
    {{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you From fd2d3fcfd75aa5ca5651caf150fbb6088a940d85 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 11:51:41 +0200 Subject: [PATCH 044/108] Removed historical code: recents does not need to manage presences. It is already done by initialSync in eventStreamService --- webclient/recents/recents-controller.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 701c935742..efa5160c01 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -112,12 +112,6 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand $rootScope.rooms[room.room_id].numUsersInRoom = numUsersInRoom; } - var presence = initialSyncData.data.presence; - for (var i = 0; i < presence.length; ++i) { - eventHandlerService.handleEvent(presence[i], false); - } - - // From now, update recents from the stream listenToEventStream(); }, From 472b4fe48cc97656c6ed50b817214ecad61dfcc5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 14:53:47 +0100 Subject: [PATCH 045/108] make calls work in Firefox --- webclient/components/matrix/matrix-call.js | 29 ++++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 4eaed89bcf..ae20b7650e 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -35,6 +35,20 @@ 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; + +var createPeerConnection = function() { + var stunServer = 'stun:stun.l.google.com:19302'; + if (window.mozRTCPeerConnection) { + return new window.mozRTCPeerConnection({'url': stunServer}); + } else { + return new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}); + } +} + angular.module('MatrixCall', []) .factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope) { var MatrixCall = function(room_id) { @@ -44,10 +58,6 @@ angular.module('MatrixCall', []) this.didConnect = false; } - navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; - - window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection; - MatrixCall.prototype.placeCall = function() { self = this; matrixPhoneService.callPlaced(this); @@ -58,7 +68,7 @@ angular.module('MatrixCall', []) MatrixCall.prototype.initWithInvite = function(msg) { this.msg = msg; - this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}) + this.peerConn = createPeerConnection(); self= this; this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); }; this.peerConn.onicecandidate = function(c) { self.gotLocalIceCandidate(c); }; @@ -79,12 +89,12 @@ angular.module('MatrixCall', []) MatrixCall.prototype.stopAllMedia = function() { if (this.localAVStream) { forAllTracksOnStream(this.localAVStream, function(t) { - t.stop(); + if (t.stop) t.stop(); }); } if (this.remoteAVStream) { forAllTracksOnStream(this.remoteAVStream, function(t) { - t.stop(); + if (t.stop) t.stop(); }); } }; @@ -93,6 +103,7 @@ angular.module('MatrixCall', []) console.trace("Ending call "+this.call_id); this.stopAllMedia(); + this.peerConn.close(); var content = { version: 0, @@ -108,7 +119,7 @@ angular.module('MatrixCall', []) for (var i = 0; i < audioTracks.length; i++) { audioTracks[i].enabled = true; } - this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}) + this.peerConn = createPeerConnection(); self = this; this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); }; this.peerConn.onsignalingstatechange = function() { self.onSignallingStateChanged(); }; @@ -275,6 +286,7 @@ angular.module('MatrixCall', []) $rootScope.$apply(function() { self.state = 'ended'; self.stopAllMedia(); + this.peerConn.close(); self.onHangup(); }); }; @@ -289,6 +301,7 @@ angular.module('MatrixCall', []) MatrixCall.prototype.onHangupReceived = function() { this.state = 'ended'; this.stopAllMedia(); + this.peerConn.close(); this.onHangup(); }; From 332986ba4329625d33c7957f0f4959825417e5aa Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 14:18:08 +0200 Subject: [PATCH 046/108] BF: prevent joined messages to be displayed twice when joining a room. Do this by synchronizing the m.room.member joined event from the events stream and the start of the pagination --- webclient/room/room-controller.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 68845df7d1..f81c3df7d2 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -32,7 +32,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) first_pagination: true, // this is toggled off when the first pagination is done can_paginate: true, // this is toggled off when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents - stream_failure: undefined // the response when the stream fails + stream_failure: undefined, // the response when the stream fails + waiting_for_joined_event: false // true when the join request is pending. Back to false once the corresponding m.room.member event is received }; $scope.members = {}; $scope.autoCompleting = false; @@ -113,8 +114,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { if (isLive) { - scrollToBottom(); - updateMemberList(event); + if ($scope.state.waiting_for_joined_event) { + // The user has successfully joined the room, we can getting data for this room + $scope.state.waiting_for_joined_event = false; + onInit3(); + } + else { + scrollToBottom(); + updateMemberList(event); + } } }); @@ -628,10 +636,14 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Do we to join the room before starting? if (needsToJoin) { + $scope.state.waiting_for_joined_event = true; matrixService.join($scope.room_id).then( function() { + // onInit3 will be called once the joined m.room.member event is received from the events stream + // This avoids to get the joined information twice in parallel: + // - one from the events stream + // - one from the pagination because the pagination window covers this event ts console.log("Joined room "+$scope.room_id); - onInit3(); }, function(reason) { console.log("Can't join room: " + JSON.stringify(reason)); From 5132fcdb8bd4dc58fb6dcfb9b0f386a8c9808ac1 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 16:10:07 +0200 Subject: [PATCH 047/108] Made recents list display something when joining a room which we do not have state data yet --- webclient/recents/recents-controller.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index efa5160c01..9fe369828b 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -43,6 +43,14 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand // Refresh the list on matrix invitation and message event $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { if (isLive) { + if (!$rootScope.rooms[event.room_id]) { + // The user has joined a new room, which we do not have data yet. The reason is that + // the room has appeared in the scope of the user rooms after the global initialSync + // FIXME: an initialSync on this specific room should be done + $rootScope.rooms[event.room_id] = { + room_id:event.room_id + }; + } $rootScope.rooms[event.room_id].lastMsg = event; } }); From 746ed57c0e5a2d49388c99ba1a51fca0bc207096 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 16:31:50 +0200 Subject: [PATCH 048/108] When the user has been kicked or banned from a room, remove the room from his recents list --- webclient/recents/recents-controller.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 9fe369828b..0553eb9be0 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -51,7 +51,14 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand room_id:event.room_id }; } - $rootScope.rooms[event.room_id].lastMsg = event; + else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) { + // The user has been kicked or banned from the room, remove this room from the recents + delete $rootScope.rooms[event.room_id]; + } + + if ($rootScope.rooms[event.room_id]) { + $rootScope.rooms[event.room_id].lastMsg = event; + } } }); $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { From 1ef51e79393b98ecf874dce298892f32570dcdd4 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 9 Sep 2014 16:46:30 +0200 Subject: [PATCH 049/108] Improved room page loading flow: do pagination only when the members list is available. Killed an unexpected pagination trigger when the page load: paginateMore --- webclient/room/room-controller.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index f81c3df7d2..59f64061cd 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -30,7 +30,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) events_from: "END", // when to start the event stream from. earliest_token: "END", // stores how far back we've paginated. first_pagination: true, // this is toggled off when the first pagination is done - can_paginate: true, // this is toggled off when we run out of items + can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents stream_failure: undefined, // the response when the stream fails waiting_for_joined_event: false // true when the join request is pending. Back to false once the corresponding m.room.member event is received @@ -679,13 +679,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Arm list timing update timer updateMemberListPresenceAge(); + + // Start pagination + $scope.state.can_paginate = true; + paginate(MESSAGES_PER_PAGINATION); }, function(error) { $scope.feedback = "Failed get member list: " + error.data.error; } ); - - paginate(MESSAGES_PER_PAGINATION); }; $scope.inviteUser = function(user_id) { From a75f8686ba4c536db1a9e341786ac34bab3d25c7 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Tue, 9 Sep 2014 16:27:59 +0100 Subject: [PATCH 050/108] Fix bug where we used an unbound local variable if we ended up rolling back the persist_event transaction --- synapse/storage/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index a2eec3b209..ad2a484c16 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -77,7 +77,7 @@ class DataStore(RoomMemberStore, RoomStore, stream_ordering = self.min_token try: - latest = yield self._db_pool.runInteraction( + yield self._db_pool.runInteraction( self._persist_pdu_event_txn, pdu=pdu, event=event, @@ -86,7 +86,6 @@ class DataStore(RoomMemberStore, RoomStore, ) except _RollbackButIsFineException as e: pass - defer.returnValue(latest) @defer.inlineCallbacks def get_event(self, event_id, allow_none=False): @@ -214,8 +213,6 @@ class DataStore(RoomMemberStore, RoomStore, } ) - return self._get_room_events_max_id_txn(txn) - @defer.inlineCallbacks def get_current_state(self, room_id, event_type=None, state_key=""): sql = ( From 253c327252a3455aa5ce3a3147e19406a4d67615 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 17:37:50 +0100 Subject: [PATCH 051/108] Don't play an engaged tone if we hang up locally. --- webclient/app-controller.js | 2 +- webclient/components/matrix/matrix-call.js | 11 ++++++++--- webclient/room/room-controller.js | 10 +++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 20b5076727..4a57f66ef0 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -118,7 +118,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even angular.element('#ringAudio')[0].pause(); angular.element('#ringbackAudio')[0].pause(); angular.element('#callendAudio')[0].play(); - } else if (newVal == 'ended' && oldVal == 'invite_sent') { + } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'remote') { angular.element('#ringAudio')[0].pause(); angular.element('#ringbackAudio')[0].pause(); angular.element('#busyAudio')[0].play(); diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index ae20b7650e..aae00a3f77 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -58,12 +58,13 @@ angular.module('MatrixCall', []) this.didConnect = false; } - MatrixCall.prototype.placeCall = function() { + MatrixCall.prototype.placeCall = function(config) { self = this; matrixPhoneService.callPlaced(this); - navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); }); - self.state = 'wait_local_media'; + navigator.getUserMedia({audio: config.audio, video: config.video}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); }); + this.state = 'wait_local_media'; this.direction = 'outbound'; + this.config = config; }; MatrixCall.prototype.initWithInvite = function(msg) { @@ -105,6 +106,8 @@ angular.module('MatrixCall', []) this.stopAllMedia(); this.peerConn.close(); + this.hangupParty = 'local'; + var content = { version: 0, call_id: this.call_id, @@ -285,6 +288,7 @@ angular.module('MatrixCall', []) self = this; $rootScope.$apply(function() { self.state = 'ended'; + this.hangupParty = 'remote'; self.stopAllMedia(); this.peerConn.close(); self.onHangup(); @@ -300,6 +304,7 @@ angular.module('MatrixCall', []) MatrixCall.prototype.onHangupReceived = function() { this.state = 'ended'; + this.hangupParty = 'remote'; this.stopAllMedia(); this.peerConn.close(); this.onHangup(); diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 59f64061cd..3d75ef5499 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -756,7 +756,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var call = new MatrixCall($scope.room_id); call.onError = $rootScope.onCallError; call.onHangup = $rootScope.onCallHangup; - call.placeCall(); + call.placeCall({audio: true, video: false}); + $rootScope.currentCall = call; + }; + + $scope.startVideoCall = function() { + var call = new MatrixCall($scope.room_id); + call.onError = $rootScope.onCallError; + call.onHangup = $rootScope.onCallHangup; + call.placeCall({audio: true, video: true}); $rootScope.currentCall = call; }; From 25e96f82db21fe0216f748e53aadb8d9dac3da72 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 17:52:01 +0100 Subject: [PATCH 052/108] Don't break if you press the hangup button before allowing media permission. --- webclient/components/matrix/matrix-call.js | 2 +- webclient/index.html | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index aae00a3f77..ef35717da6 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -104,7 +104,7 @@ angular.module('MatrixCall', []) console.trace("Ending call "+this.call_id); this.stopAllMedia(); - this.peerConn.close(); + if (this.peerConn) this.peerConn.close(); this.hangupParty = 'local'; diff --git a/webclient/index.html b/webclient/index.html index 53ac1cb10e..3b531027e1 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -58,7 +58,8 @@ Calling... Call Connecting... Call Connected - Call Rejected + Call Rejected + Call Canceled Call Ended Call Canceled Call Ended From ccfb42e4ff612cab14318e16436984d2554a13c0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 17:58:26 +0100 Subject: [PATCH 053/108] Don't try setting up the call if the user has canceled it before allowing permission. --- webclient/components/matrix/matrix-call.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index ef35717da6..16f22fe364 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -117,6 +117,8 @@ angular.module('MatrixCall', []) }; MatrixCall.prototype.gotUserMediaForInvite = function(stream) { + if (!$rootScope.currentCall || $rootScope.currentCall.state == 'ended') return; + this.localAVStream = stream; var audioTracks = stream.getAudioTracks(); for (var i = 0; i < audioTracks.length; i++) { @@ -140,6 +142,8 @@ angular.module('MatrixCall', []) }; MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { + if (!$rootScope.currentCall || $rootScope.currentCall.state == 'ended') return; + this.localAVStream = stream; var audioTracks = stream.getAudioTracks(); for (var i = 0; i < audioTracks.length; i++) { From f90ce04a8318d00df9db70e1b4cae500785a7d44 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Sep 2014 18:21:03 +0100 Subject: [PATCH 054/108] Hangup call if user denies media access. --- webclient/app-controller.js | 8 -------- webclient/components/matrix/matrix-call.js | 2 ++ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 4a57f66ef0..f28da87ccc 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -142,14 +142,6 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $scope.hangupCall = function() { $rootScope.currentCall.hangup(); - - $timeout(function() { - var icon = angular.element('#callEndedIcon'); - $animate.addClass(icon, 'callIconRotate'); - $timeout(function(){ - $rootScope.currentCall = undefined; - }, 4070); - }, 100); }; $rootScope.onCallError = function(errStr) { diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 16f22fe364..feb113f60d 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -114,6 +114,7 @@ angular.module('MatrixCall', []) }; matrixService.sendEvent(this.room_id, 'm.call.hangup', undefined, content).then(this.messageSent, this.messageSendFailed); this.state = 'ended'; + self.onHangup(); }; MatrixCall.prototype.gotUserMediaForInvite = function(stream) { @@ -233,6 +234,7 @@ angular.module('MatrixCall', []) MatrixCall.prototype.getUserMediaFailed = function() { this.onError("Couldn't start capturing audio! Is your microphone set up?"); + this.hangup(); }; MatrixCall.prototype.onIceConnectionStateChanged = function() { From 550e8f32ac7a9bc56b57b515c515f85bf264e891 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 9 Sep 2014 13:51:03 -0700 Subject: [PATCH 055/108] Move model to client-server for now. --- docs/{ => client-server}/model/presence.rst | 0 docs/{ => client-server}/model/profiles.rst | 0 docs/{ => client-server}/model/protocol_examples.rst | 0 docs/{ => client-server}/model/room-join-workflow.rst | 0 docs/{ => client-server}/model/rooms.rst | 0 docs/{ => client-server}/model/terminology.rst | 0 docs/{ => client-server}/model/third-party-id.rst | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename docs/{ => client-server}/model/presence.rst (100%) rename docs/{ => client-server}/model/profiles.rst (100%) rename docs/{ => client-server}/model/protocol_examples.rst (100%) rename docs/{ => client-server}/model/room-join-workflow.rst (100%) rename docs/{ => client-server}/model/rooms.rst (100%) rename docs/{ => client-server}/model/terminology.rst (100%) rename docs/{ => client-server}/model/third-party-id.rst (100%) diff --git a/docs/model/presence.rst b/docs/client-server/model/presence.rst similarity index 100% rename from docs/model/presence.rst rename to docs/client-server/model/presence.rst diff --git a/docs/model/profiles.rst b/docs/client-server/model/profiles.rst similarity index 100% rename from docs/model/profiles.rst rename to docs/client-server/model/profiles.rst diff --git a/docs/model/protocol_examples.rst b/docs/client-server/model/protocol_examples.rst similarity index 100% rename from docs/model/protocol_examples.rst rename to docs/client-server/model/protocol_examples.rst diff --git a/docs/model/room-join-workflow.rst b/docs/client-server/model/room-join-workflow.rst similarity index 100% rename from docs/model/room-join-workflow.rst rename to docs/client-server/model/room-join-workflow.rst diff --git a/docs/model/rooms.rst b/docs/client-server/model/rooms.rst similarity index 100% rename from docs/model/rooms.rst rename to docs/client-server/model/rooms.rst diff --git a/docs/model/terminology.rst b/docs/client-server/model/terminology.rst similarity index 100% rename from docs/model/terminology.rst rename to docs/client-server/model/terminology.rst diff --git a/docs/model/third-party-id.rst b/docs/client-server/model/third-party-id.rst similarity index 100% rename from docs/model/third-party-id.rst rename to docs/client-server/model/third-party-id.rst From d5704cf2a3c6e8d27a6f70bca0db499e04ce6eb9 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 9 Sep 2014 14:53:35 -0700 Subject: [PATCH 056/108] Added initial draft for human-readable ID rules. --- docs/human-id-rules.rst | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/human-id-rules.rst diff --git a/docs/human-id-rules.rst b/docs/human-id-rules.rst new file mode 100644 index 0000000000..36987ddd0d --- /dev/null +++ b/docs/human-id-rules.rst @@ -0,0 +1,71 @@ +This document outlines the format for human-readable IDs within matrix. + +Overview +-------- +UTF-8 is quickly becoming the standard character encoding set on the web. As +such, Matrix requires that all strings MUST be encoded as UTF-8. However, +using Unicode as the character set for human-readable IDs is troublesome. There +are many different characters which appear identical to each other, but would +identify different users. In addition, there are non-printable characters which +cannot be rendered the the end-user. This opens up a security vulnerability with +phishing/spoofing of IDs, commonly known as a homograph attack. + +Web browers encountered this problem when International Domain Names were +introduced. A variety of checks were put in place in order to protect users. If +an address failed the check, the raw punycode would be displayed to disambiguate +the address. Similar checks are performed by home servers in Matrix, which will +then warn the client about the potentially misleading ID. However, Matrix does +not use punycode, and so does not show raw punycode on a failed check. Instead, +home servers must outright reject these misleading IDs. + +Types of human-readable IDs +--------------------------- +There are two main human-readable IDs in question: + + - Room aliases + - User IDs + +Room aliases look like ``#localpart:domain``. These aliases point to opaque +non human-readable room IDs. These pointers can change, so there is already an +issue present with the same ID pointing to a different destination at a later +date. + +User IDs look like ``@localpart:domain``. These represent actual end-users, and +unlike room aliases, there is no layer of indirection. This presents a much +greater concern with homograph attacks. + +Checks +------ +- Similar to web browsers. +- blacklisted chars (e.g. non-printable characters) +- mix of language sets from 'preferred' language not allowed. +- Language sets from CLDR dataset. +- Treated in segments (localpart, domain) + +Rejecting +--------- +- Home servers MUST reject room aliases which do not pass the check, both on + GETs and PUTs. +- Home servers MUST reject user ID localparts which do not pass the check, both + on creation and on events. +- Any home server whose domain does not pass this check, MUST use their punycode + domain name instead of the IDN, to prevent other home servers rejecting you. +- Error code is M_FAILED_HOMOGRAPH_CHECK. +- Error message MAY go into further information about which characters were + rejected and why. + +Other considerations +-------------------- +- Basic security: Informational key on the event attached by HS to say "unsafe + ID". Problem: clients can just ignore it, and since it will appear only very + rarely, easy to forget when implementing clients. +- Moderate security: Requires client handshake. Forces clients to implement + a check, else they cannot communicate with the misleading ID. However, this is + extra overhead in both client implementations and round-trips. +- High security: Outright rejection of the ID at the point of creation / + receiving event. Point of creation rejection is preferable to avoid the ID + entering the system in the first place. However, malicious HSes can just allow + the ID. Hence, other home servers must reject them if they see them in events. + Client never sees the problem ID, provided the HS is correctly implemented. +- High security decided; client doesn't need to worry about it, no additional + protocol complexity aside from rejection of an event. \ No newline at end of file From 56a358481e928d6e70ff8afd48756c67860965c9 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 9 Sep 2014 15:00:48 -0700 Subject: [PATCH 057/108] Tyops --- docs/human-id-rules.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/human-id-rules.rst b/docs/human-id-rules.rst index 36987ddd0d..999651991c 100644 --- a/docs/human-id-rules.rst +++ b/docs/human-id-rules.rst @@ -7,23 +7,23 @@ such, Matrix requires that all strings MUST be encoded as UTF-8. However, using Unicode as the character set for human-readable IDs is troublesome. There are many different characters which appear identical to each other, but would identify different users. In addition, there are non-printable characters which -cannot be rendered the the end-user. This opens up a security vulnerability with +cannot be rendered by the end-user. This opens up a security vulnerability with phishing/spoofing of IDs, commonly known as a homograph attack. Web browers encountered this problem when International Domain Names were introduced. A variety of checks were put in place in order to protect users. If an address failed the check, the raw punycode would be displayed to disambiguate -the address. Similar checks are performed by home servers in Matrix, which will -then warn the client about the potentially misleading ID. However, Matrix does -not use punycode, and so does not show raw punycode on a failed check. Instead, -home servers must outright reject these misleading IDs. +the address. Similar checks are performed by home servers in Matrix. However, +Matrix does not use punycode representations, and so does not show raw punycode +on a failed check. Instead, home servers must outright reject these misleading +IDs. Types of human-readable IDs --------------------------- There are two main human-readable IDs in question: - - Room aliases - - User IDs +- Room aliases +- User IDs Room aliases look like ``#localpart:domain``. These aliases point to opaque non human-readable room IDs. These pointers can change, so there is already an From f23e5b17b66db0fabb8c53d3f046936268e5e031 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 9 Sep 2014 15:11:06 -0700 Subject: [PATCH 058/108] Extra restrictions to make parsing easier. --- docs/human-id-rules.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/human-id-rules.rst b/docs/human-id-rules.rst index 999651991c..6e63bc43a2 100644 --- a/docs/human-id-rules.rst +++ b/docs/human-id-rules.rst @@ -41,6 +41,9 @@ Checks - mix of language sets from 'preferred' language not allowed. - Language sets from CLDR dataset. - Treated in segments (localpart, domain) +- Additional restrictions for ease of processing IDs. + - Room alias localparts MUST NOT have ``#`` or ``:``. + - User ID localparts MUST NOT have ``@`` or ``:``. Rejecting --------- @@ -50,9 +53,13 @@ Rejecting on creation and on events. - Any home server whose domain does not pass this check, MUST use their punycode domain name instead of the IDN, to prevent other home servers rejecting you. -- Error code is M_FAILED_HOMOGRAPH_CHECK. +- Error code is ``M_FAILED_HUMAN_ID_CHECK``. (generic enough for both failing + due to homograph attacks, and failing due to including ``:``s, etc) - Error message MAY go into further information about which characters were rejected and why. +- Error message SHOULD contain a ``failed_keys`` key which contains an array + of strings which represent the keys which failed the check e.g: + - ``failed_keys: [ user_id, room_alias ]`` Other considerations -------------------- From 2bd4346075b119d48afa676dcc883a51199119f2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 9 Sep 2014 15:13:50 -0700 Subject: [PATCH 059/108] More rst formatting. --- docs/human-id-rules.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/human-id-rules.rst b/docs/human-id-rules.rst index 6e63bc43a2..3a1ff39892 100644 --- a/docs/human-id-rules.rst +++ b/docs/human-id-rules.rst @@ -42,8 +42,8 @@ Checks - Language sets from CLDR dataset. - Treated in segments (localpart, domain) - Additional restrictions for ease of processing IDs. - - Room alias localparts MUST NOT have ``#`` or ``:``. - - User ID localparts MUST NOT have ``@`` or ``:``. + - Room alias localparts MUST NOT have ``#`` or ``:``. + - User ID localparts MUST NOT have ``@`` or ``:``. Rejecting --------- @@ -54,12 +54,13 @@ Rejecting - Any home server whose domain does not pass this check, MUST use their punycode domain name instead of the IDN, to prevent other home servers rejecting you. - Error code is ``M_FAILED_HUMAN_ID_CHECK``. (generic enough for both failing - due to homograph attacks, and failing due to including ``:``s, etc) + due to homograph attacks, and failing due to including ``:`` s, etc) - Error message MAY go into further information about which characters were rejected and why. - Error message SHOULD contain a ``failed_keys`` key which contains an array - of strings which represent the keys which failed the check e.g: - - ``failed_keys: [ user_id, room_alias ]`` + of strings which represent the keys which failed the check e.g:: + + failed_keys: [ user_id, room_alias ] Other considerations -------------------- From 6f256e6380e4cb278af5dd5ae57460f0da2b2cf1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Sep 2014 10:32:05 +0100 Subject: [PATCH 060/108] reject calls if there's already a call in progress --- webclient/app-controller.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index f28da87ccc..f8a0d8d35c 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -131,6 +131,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) { console.trace("incoming call"); + if ($rootScope.currentCall && $rootScope.currentCall.state != 'ended') { + console.trace("rejecting call because we're already in a call"); + call.hangup(); + } call.onError = $scope.onCallError; call.onHangup = $scope.onCallHangup; $rootScope.currentCall = call; From b63dd9506ea286f8bdaffd213fa79a382933eb35 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 12:01:00 +0200 Subject: [PATCH 061/108] Improved requests: pagination is done from the data received in initialSync --- .../matrix/event-handler-service.js | 28 +++++++++++++++---- .../components/matrix/event-stream-service.js | 9 +++++- webclient/room/room-controller.js | 16 +++++------ 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 94ac91db5e..24d634a28b 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -55,6 +55,11 @@ angular.module('eventHandlerService', []) $rootScope.events.rooms[room_id] = {}; $rootScope.events.rooms[room_id].messages = []; $rootScope.events.rooms[room_id].members = {}; + + // Pagination information + $rootScope.events.rooms[room_id].pagination = { + earliest_token: "END" // how far back we've paginated + } } }; @@ -187,17 +192,21 @@ angular.module('eventHandlerService', []) NAME_EVENT: NAME_EVENT, handleEvent: function(event, isLiveEvent) { - // FIXME: event duplication suppression is all broken as the code currently expect to handles - // events multiple times to get their side-effects... -/* + // Avoid duplicated events + // Needed for rooms where initialSync has not been done. + // In this case, we do not know where to start pagination. So, it starts from the END + // and we can have the same event (ex: joined, invitation) coming from the pagination + // AND from the event stream. + // FIXME: This workaround should be no more required when /initialSync on a particular room + // will be available (as opposite to the global /initialSync done at startup) if (eventMap[event.event_id]) { - console.log("discarding duplicate event: " + JSON.stringify(event)); + console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4)); return; } else { eventMap[event.event_id] = 1; } -*/ + if (event.type.indexOf('m.call.') === 0) { handleCallEvent(event, isLiveEvent); } @@ -247,6 +256,15 @@ angular.module('eventHandlerService', []) } }, + // Handle messages from /initialSync or /messages + handleRoomMessages: function(room_id, messages, isLiveEvents) { + this.handleEvents(messages.chunk); + + // Store how far back we've paginated + // This assumes the paginations requests are contiguous and in reverse chronological order + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + }, + handleInitialSyncDone: function(initialSyncData) { console.log("# handleInitialSyncDone"); initialSyncDeferred.resolve(initialSyncData); diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 1bc850a8fa..d7ccc63e89 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -112,9 +112,16 @@ angular.module('eventStreamService', []) var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { var room = rooms[i]; + // console.log("got room: " + room.room_id); if ("state" in room) { - eventHandlerService.handleEvents(room.state, false); + //eventHandlerService.handleEvents(room.state, false); + } + + if ("messages" in room) { + eventHandlerService.handleRoomMessages(room.room_id, room.messages, false); + + console.log(room.messages.start + " - " + room.messages.end); } } diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 3d75ef5499..9bb0d8e2d4 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -27,8 +27,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.state = { user_id: matrixService.config().user_id, - events_from: "END", // when to start the event stream from. - earliest_token: "END", // stores how far back we've paginated. first_pagination: true, // this is toggled off when the first pagination is done can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents @@ -159,12 +157,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) else { $scope.state.paginating = true; } - // console.log("paginateBackMessages from " + $scope.state.earliest_token + " for " + numItems); + + console.log("paginateBackMessages from " + $rootScope.events.rooms[$scope.room_id].pagination.earliest_token + " for " + numItems); var originalTopRow = $("#messageTable>tbody>tr:first")[0]; - matrixService.paginateBackMessages($scope.room_id, $scope.state.earliest_token, numItems).then( + + // Paginate events from the point in cache + matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then( function(response) { - eventHandlerService.handleEvents(response.data.chunk, false); - $scope.state.earliest_token = response.data.end; + + eventHandlerService.handleRoomMessages($scope.room_id, response.data, false); if (response.data.chunk.length < MESSAGES_PER_PAGINATION) { // no more messages to paginate. this currently never gets turned true again, as we never // expire paginated contents in the current implementation. @@ -659,9 +660,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var onInit3 = function() { console.log("onInit3"); - - // TODO: We should be able to keep them - eventHandlerService.resetRoomMessages($scope.room_id); // Make recents highlight the current room $scope.recentsSelectedRoomID = $scope.room_id; From 55fe0d8adce870cc665f5e68f9cbfaf36ba2617a Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Sep 2014 11:12:02 +0100 Subject: [PATCH 062/108] Less buggy rejection of calls when busy --- webclient/app-controller.js | 19 +++++++++++-------- webclient/components/matrix/matrix-call.js | 14 +++++++++----- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index f8a0d8d35c..55397ed216 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -134,6 +134,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even if ($rootScope.currentCall && $rootScope.currentCall.state != 'ended') { console.trace("rejecting call because we're already in a call"); call.hangup(); + return; } call.onError = $scope.onCallError; call.onHangup = $scope.onCallHangup; @@ -152,13 +153,15 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $scope.feedback = errStr; } - $rootScope.onCallHangup = function() { - $timeout(function() { - var icon = angular.element('#callEndedIcon'); - $animate.addClass(icon, 'callIconRotate'); - $timeout(function(){ - $rootScope.currentCall = undefined; - }, 4070); - }, 100); + $rootScope.onCallHangup = function(call) { + if (call == $rootScope.currentCall) { + $timeout(function() { + var icon = angular.element('#callEndedIcon'); + $animate.addClass(icon, 'callIconRotate'); + $timeout(function(){ + $rootScope.currentCall = undefined; + }, 4070); + }, 100); + } } }]); diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index feb113f60d..68bde78862 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -114,7 +114,7 @@ angular.module('MatrixCall', []) }; matrixService.sendEvent(this.room_id, 'm.call.hangup', undefined, content).then(this.messageSent, this.messageSendFailed); this.state = 'ended'; - self.onHangup(); + if (self.onHangup) self.onHangup(self); }; MatrixCall.prototype.gotUserMediaForInvite = function(stream) { @@ -178,6 +178,10 @@ angular.module('MatrixCall', []) MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { console.trace("Got ICE candidate from remote: "+cand); + if (this.state == 'ended') { + console.trace("Ignoring remote ICE candidate because call has ended"); + return; + } var candidateObject = new RTCIceCandidate({ sdpMLineIndex: cand.label, candidate: cand.candidate @@ -294,10 +298,10 @@ angular.module('MatrixCall', []) self = this; $rootScope.$apply(function() { self.state = 'ended'; - this.hangupParty = 'remote'; + self.hangupParty = 'remote'; self.stopAllMedia(); - this.peerConn.close(); - self.onHangup(); + if (self.peerConn.signalingState != 'closed') self.peerConn.close(); + if (self.onHangup) self.onHangup(self); }); }; @@ -313,7 +317,7 @@ angular.module('MatrixCall', []) this.hangupParty = 'remote'; this.stopAllMedia(); this.peerConn.close(); - this.onHangup(); + if (this.onHangup) this.onHangup(self); }; return MatrixCall; From 7411794fa1d584a3be4c553406ab6406dee2f2e2 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Sep 2014 11:21:20 +0100 Subject: [PATCH 063/108] Show mxid in call bar for users with no displayname --- webclient/app-controller.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 55397ed216..feda0f6b57 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -96,9 +96,14 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even delete roomMembers[matrixService.config().user_id]; $rootScope.currentCall.user_id = Object.keys(roomMembers)[0]; + + // set it to the user ID until we fetch the display name + $rootScope.currentCall.userProfile = { displayname: $rootScope.currentCall.user_id }; + matrixService.getProfile($rootScope.currentCall.user_id).then( function(response) { - $rootScope.currentCall.userProfile = response.data; + if (response.data.displayname) $rootScope.currentCall.userProfile.displayname = response.data.displayname; + if (response.data.avatar_url) $rootScope.currentCall.userProfile.avatar_url = response.data.avatar_url; }, function(error) { $scope.feedback = "Can't load user profile"; From 80b54706638df76b90ca9e6edc65db4f74fdfc7e Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Sep 2014 11:35:14 +0100 Subject: [PATCH 064/108] Add text for incoming calls --- webclient/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/webclient/index.html b/webclient/index.html index 3b531027e1..150b7c4407 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -56,6 +56,7 @@
    Calling... + Incoming Call Call Connecting... Call Connected Call Rejected From c2afc6cd0a4f375149956b11b471a8f5d840afde Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 13:48:33 +0200 Subject: [PATCH 065/108] Presence events do not have event id. Do not discard them --- webclient/components/matrix/event-handler-service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 24d634a28b..14ac79dd8f 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -199,7 +199,7 @@ angular.module('eventHandlerService', []) // AND from the event stream. // FIXME: This workaround should be no more required when /initialSync on a particular room // will be available (as opposite to the global /initialSync done at startup) - if (eventMap[event.event_id]) { + if (event.event_id && eventMap[event.event_id]) { console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4)); return; } @@ -258,7 +258,7 @@ angular.module('eventHandlerService', []) // Handle messages from /initialSync or /messages handleRoomMessages: function(room_id, messages, isLiveEvents) { - this.handleEvents(messages.chunk); + this.handleEvents(messages.chunk, isLiveEvents); // Store how far back we've paginated // This assumes the paginations requests are contiguous and in reverse chronological order From b099634ba1dcc7bcc0e591fd511d7045aea4ef91 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 14:36:30 +0200 Subject: [PATCH 066/108] Reenabled handle of room states events in initialSync but do not add them to the displayed messages in the room page. Show the m.room.member events only when they come from room.messages (from initialSync of pagination) not from room.state. --- .../components/matrix/event-handler-service.js | 15 +++++++++------ .../components/matrix/event-stream-service.js | 11 ++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 14ac79dd8f..d7705c8e3e 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -105,7 +105,7 @@ angular.module('eventHandlerService', []) $rootScope.$broadcast(MSG_EVENT, event, isLiveEvent); }; - var handleRoomMember = function(event, isLiveEvent) { + var handleRoomMember = function(event, isLiveEvent, isStateEvent) { initRoom(event.room_id); // if the server is stupidly re-relaying a no-op join, discard it. @@ -117,7 +117,10 @@ angular.module('eventHandlerService', []) } // add membership changes as if they were a room message if something interesting changed - if (event.content.prev !== event.content.membership) { + // Exception: Do not do this if the event is a room state event because such events already come + // as room messages events. Moreover, when they come as room messages events, they are relatively ordered + // with other other room messages + if (event.content.prev !== event.content.membership && !isStateEvent) { if (isLiveEvent) { $rootScope.events.rooms[event.room_id].messages.push(event); } @@ -191,7 +194,7 @@ angular.module('eventHandlerService', []) CALL_EVENT: CALL_EVENT, NAME_EVENT: NAME_EVENT, - handleEvent: function(event, isLiveEvent) { + handleEvent: function(event, isLiveEvent, isStateEvent) { // Avoid duplicated events // Needed for rooms where initialSync has not been done. // In this case, we do not know where to start pagination. So, it starts from the END @@ -222,7 +225,7 @@ angular.module('eventHandlerService', []) handleMessage(event, isLiveEvent); break; case "m.room.member": - handleRoomMember(event, isLiveEvent); + handleRoomMember(event, isLiveEvent, isStateEvent); break; case "m.presence": handlePresence(event, isLiveEvent); @@ -250,9 +253,9 @@ angular.module('eventHandlerService', []) // isLiveEvents determines whether notifications should be shown, whether // messages get appended to the start/end of lists, etc. - handleEvents: function(events, isLiveEvents) { + handleEvents: function(events, isLiveEvents, isStateEvents) { for (var i=0; i Date: Wed, 10 Sep 2014 14:45:32 +0200 Subject: [PATCH 067/108] dedup events: state events conflict with messages events. Do not consider them in deduplication --- .../components/matrix/event-handler-service.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index d7705c8e3e..80a15182ae 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -202,14 +202,16 @@ angular.module('eventHandlerService', []) // AND from the event stream. // FIXME: This workaround should be no more required when /initialSync on a particular room // will be available (as opposite to the global /initialSync done at startup) - if (event.event_id && eventMap[event.event_id]) { - console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4)); - return; + if (!isStateEvent) { // Do not consider state events + if (event.event_id && eventMap[event.event_id]) { + console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4)); + return; + } + else { + eventMap[event.event_id] = 1; + } } - else { - eventMap[event.event_id] = 1; - } - + if (event.type.indexOf('m.call.') === 0) { handleCallEvent(event, isLiveEvent); } From da3f842b8cea7d259d4d2d980020625b9703b1ee Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 14:53:03 +0200 Subject: [PATCH 068/108] Removed wrong comments about recents-controller.js: it uses $rootScope.rooms not $rootScope.events.rooms managed by event-handler-service.js and used by other controllers --- .../components/matrix/event-stream-service.js | 5 ++--- webclient/recents/recents-controller.js | 22 ------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 249af193df..03b805213d 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -104,9 +104,7 @@ angular.module('eventStreamService', []) settings.isActive = true; var deferred = $q.defer(); - // FIXME: We are discarding all the messages. - // XXX FIXME TODO : The discard works because we are doing this all over - // again on EVERY INSTANTIATION of the recents controller. + // Initial sync: get all information and the last message of all rooms of the user matrixService.initialSync(1, false).then( function(response) { var rooms = response.data.rooms; @@ -128,6 +126,7 @@ angular.module('eventStreamService', []) // Initial sync is done eventHandlerService.handleInitialSyncDone(response); + // Start event streaming from that point settings.from = response.data.end; doEventStream(deferred); }, diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 0553eb9be0..aedc7b7a49 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -16,12 +16,6 @@ 'use strict'; -// XXX FIXME TODO -// We should NOT be dumping things into $rootScope!!!! We should NOT be -// making any requests here, and should READ what is already in the -// rootScope from the event handler service!!! -// XXX FIXME TODO - angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) .controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', function($rootScope, $scope, matrixService, eventHandlerService) { @@ -33,11 +27,6 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand // $rootScope of the parent where the recents component is included can override this value // in order to highlight a specific room in the list $rootScope.recentsSelectedRoomID; - - // XXX FIXME TODO : We should NOT be doing this here, which could be - // repeated for every controller instance. We should be doing this in - // event handler service instead. In additon, this will break if there - // isn't a recents controller visible when the last message comes in :/ var listenToEventStream = function() { // Refresh the list on matrix invitation and message event @@ -85,23 +74,12 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand return; } - // XXX FIXME TODO - // We should NOT be dumping things into $rootScope!!!! We should NOT be - // making any requests here, and should READ what is already in the - // rootScope from the event handler service!!! - // XXX FIXME TODO - $rootScope.rooms = {}; // Use initialSync data to init the recents list eventHandlerService.waitForInitialSyncCompletion().then( function(initialSyncData) { - // XXX FIXME TODO: - // Any assignments to the rootScope here should be done in - // event handler service and not here, because we could have - // many controllers manipulating and clobbering each other, and - // are unecessarily repeating http requests. var rooms = initialSyncData.data.rooms; for (var i=0; i Date: Wed, 10 Sep 2014 16:26:11 +0200 Subject: [PATCH 069/108] Member event: store use the the latest one --- webclient/components/matrix/event-handler-service.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 80a15182ae..38b7bd6b6d 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -129,8 +129,13 @@ angular.module('eventHandlerService', []) } } - $rootScope.events.rooms[event.room_id].members[event.state_key] = event; - $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent); + // Use data from state event or the latest data from the stream. + // Do not care of events that come when paginating back + if (isStateEvent || isLiveEvent) { + $rootScope.events.rooms[event.room_id].members[event.state_key] = event; + } + + $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent, isStateEvent); }; var handlePresence = function(event, isLiveEvent) { From dde7ec8e64c496fec23b3e6249b86873660a8324 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Sep 2014 15:43:27 +0100 Subject: [PATCH 070/108] Upgrade angularjs to 1.3.0-rc1 since this is new development --- webclient/js/angular-animate.js | 1051 ++-- webclient/js/angular-animate.min.js | 43 +- webclient/js/angular-route.js | 61 +- webclient/js/angular-route.min.js | 20 +- webclient/js/angular-sanitize.js | 278 +- webclient/js/angular-sanitize.min.js | 21 +- webclient/js/angular.js | 7909 +++++++++++++++++--------- webclient/js/angular.min.js | 429 +- 8 files changed, 6372 insertions(+), 3440 deletions(-) diff --git a/webclient/js/angular-animate.js b/webclient/js/angular-animate.js index bea4bc5232..c15f793c1b 100644 --- a/webclient/js/angular-animate.js +++ b/webclient/js/angular-animate.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.2.20 + * @license AngularJS v1.3.0-rc.1 * (c) 2010-2014 Google, Inc. http://angularjs.org * License: MIT */ @@ -12,11 +12,8 @@ * @name ngAnimate * @description * - * # ngAnimate - * * The `ngAnimate` module provides support for JavaScript, CSS3 transition and CSS3 keyframe animation hooks within existing core and custom directives. * - * *
    * * # Usage @@ -28,17 +25,18 @@ * * Below is a more detailed breakdown of the supported animation events provided by pre-existing ng directives: * - * | Directive | Supported Animations | - * |---------------------------------------------------------- |----------------------------------------------------| - * | {@link ng.directive:ngRepeat#usage_animations ngRepeat} | enter, leave and move | - * | {@link ngRoute.directive:ngView#usage_animations ngView} | enter and leave | - * | {@link ng.directive:ngInclude#usage_animations ngInclude} | enter and leave | - * | {@link ng.directive:ngSwitch#usage_animations ngSwitch} | enter and leave | - * | {@link ng.directive:ngIf#usage_animations ngIf} | enter and leave | - * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove | - * | {@link ng.directive:ngShow#usage_animations ngShow & ngHide} | add and remove (the ng-hide class value) | - * | {@link ng.directive:form#usage_animations form} | add and remove (dirty, pristine, valid, invalid & all other validations) | - * | {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | Directive | Supported Animations | + * |-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| + * | {@link ng.directive:ngRepeat#usage_animations ngRepeat} | enter, leave and move | + * | {@link ngRoute.directive:ngView#usage_animations ngView} | enter and leave | + * | {@link ng.directive:ngInclude#usage_animations ngInclude} | enter and leave | + * | {@link ng.directive:ngSwitch#usage_animations ngSwitch} | enter and leave | + * | {@link ng.directive:ngIf#usage_animations ngIf} | enter and leave | + * | {@link ng.directive:ngClass#usage_animations ngClass} | add and remove (the CSS class(es) present) | + * | {@link ng.directive:ngShow#usage_animations ngShow} & {@link ng.directive:ngHide#usage_animations ngHide} | add and remove (the ng-hide class value) | + * | {@link ng.directive:form#usage_animations form} & {@link ng.directive:ngModel#usage_animations ngModel} | add and remove (dirty, pristine, valid, invalid & all other validations) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessages} | add and remove (ng-active & ng-inactive) | + * | {@link ngMessages.directive:ngMessage#usage_animations ngMessage} | enter and leave | * * You can find out more information about animations upon visiting each directive page. * @@ -52,9 +50,9 @@ * } * * .slide.ng-enter { } /* starting animations for enter */ - * .slide.ng-enter-active { } /* terminal animations for enter */ + * .slide.ng-enter.ng-enter-active { } /* terminal animations for enter */ * .slide.ng-leave { } /* starting animations for leave */ - * .slide.ng-leave-active { } /* terminal animations for leave */ + * .slide.ng-leave.ng-leave-active { } /* terminal animations for leave */ * * * /g, DOCTYPE_REGEXP = /]*?)>/i, CDATA_REGEXP = //g, - URI_REGEXP = /^((ftp|https?):\/\/|mailto:|tel:|#)/i, + SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, // Match everything outside of normal chars and " (quote character) NON_ALPHANUMERIC_REGEXP = /([^\#-~| |!])/g; @@ -197,7 +214,7 @@ var validAttrs = angular.extend({}, uriAttrs, makeMap( 'abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,'+ 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,'+ 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,'+ - 'scope,scrolling,shape,span,start,summary,target,title,type,'+ + 'scope,scrolling,shape,size,span,start,summary,target,title,type,'+ 'valign,value,vspace,width')); function makeMap(str) { @@ -220,10 +237,18 @@ function makeMap(str) { * @param {object} handler */ function htmlParser( html, handler ) { - var index, chars, match, stack = [], last = html; + if (typeof html !== 'string') { + if (html === null || typeof html === 'undefined') { + html = ''; + } else { + html = '' + html; + } + } + var index, chars, match, stack = [], last = html, text; stack.last = function() { return stack[ stack.length - 1 ]; }; while ( html ) { + text = ''; chars = true; // Make sure we're not in a script or style element @@ -244,7 +269,7 @@ function htmlParser( html, handler ) { match = html.match( DOCTYPE_REGEXP ); if ( match ) { - html = html.replace( match[0] , ''); + html = html.replace( match[0], ''); chars = false; } // end tag @@ -262,16 +287,23 @@ function htmlParser( html, handler ) { match = html.match( START_TAG_REGEXP ); if ( match ) { - html = html.substring( match[0].length ); - match[0].replace( START_TAG_REGEXP, parseStartTag ); + // We only have a valid start-tag if there is a '>'. + if ( match[4] ) { + html = html.substring( match[0].length ); + match[0].replace( START_TAG_REGEXP, parseStartTag ); + } chars = false; + } else { + // no ending tag found --- this piece should be encoded as an entity. + text += '<'; + html = html.substring(1); } } if ( chars ) { index = html.indexOf("<"); - var text = index < 0 ? html : html.substring( 0, index ); + text += index < 0 ? html : html.substring( 0, index ); html = index < 0 ? "" : html.substring( index ); if (handler.chars) handler.chars( decodeEntities(text) ); @@ -351,15 +383,32 @@ function htmlParser( html, handler ) { } } +var hiddenPre=document.createElement("pre"); +var spaceRe = /^(\s*)([\s\S]*?)(\s*)$/; /** * decodes all entities into regular string * @param value * @returns {string} A string with decoded entities. */ -var hiddenPre=document.createElement("pre"); function decodeEntities(value) { - hiddenPre.innerHTML=value.replace(/
    * * @example - - + + -
    +
    Snippet: @@ -503,43 +560,44 @@ angular.module('ngSanitize', []).value('$sanitize', $sanitize);
    - - + + it('should linkify the snippet with urls', function() { - expect(using('#linky-filter').binding('snippet | linky')). - toBe('Pretty text with some links: ' + - 'http://angularjs.org/, ' + - 'us@somewhere.org, ' + - 'another@somewhere.org, ' + - 'and one more: ftp://127.0.0.1/.'); + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(4); }); - it ('should not linkify snippet without the linky filter', function() { - expect(using('#escaped-html').binding('snippet')). - toBe("Pretty text with some links:\n" + - "http://angularjs.org/,\n" + - "mailto:us@somewhere.org,\n" + - "another@somewhere.org,\n" + - "and one more: ftp://127.0.0.1/."); + it('should not linkify snippet without the linky filter', function() { + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, mailto:us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#escaped-html a')).count()).toEqual(0); }); it('should update', function() { - input('snippet').enter('new http://link.'); - expect(using('#linky-filter').binding('snippet | linky')). - toBe('new http://link.'); - expect(using('#escaped-html').binding('snippet')).toBe('new http://link.'); + element(by.model('snippet')).clear(); + element(by.model('snippet')).sendKeys('new http://link.'); + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('new http://link.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(1); + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()) + .toBe('new http://link.'); }); it('should work with the target property', function() { - expect(using('#linky-target').binding("snippetWithTarget | linky:'_blank'")). - toBe('http://angularjs.org/'); + expect(element(by.id('linky-target')). + element(by.binding("snippetWithTarget | linky:'_blank'")).getText()). + toBe('http://angularjs.org/'); + expect(element(by.css('#linky-target a')).getAttribute('target')).toEqual('_blank'); }); - - + + */ -angular.module('ngSanitize').filter('linky', function() { +angular.module('ngSanitize').filter('linky', ['$sanitize', function($sanitize) { var LINKY_URL_REGEXP = - /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>]/, + /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>"]/, MAILTO_REGEXP = /^mailto:/; return function(text, target) { @@ -547,31 +605,43 @@ angular.module('ngSanitize').filter('linky', function() { var match; var raw = text; var html = []; - // TODO(vojta): use $sanitize instead - var writer = htmlSanitizeWriter(html); var url; var i; - var properties = {}; - if (angular.isDefined(target)) { - properties.target = target; - } while ((match = raw.match(LINKY_URL_REGEXP))) { // We can not end in these as they are sometimes found at the end of the sentence url = match[0]; // if we did not match ftp/http/mailto then assume mailto if (match[2] == match[3]) url = 'mailto:' + url; i = match.index; - writer.chars(raw.substr(0, i)); - properties.href = url; - writer.start('a', properties); - writer.chars(match[0].replace(MAILTO_REGEXP, '')); - writer.end('a'); + addText(raw.substr(0, i)); + addLink(url, match[0].replace(MAILTO_REGEXP, '')); raw = raw.substring(i + match[0].length); } - writer.chars(raw); - return html.join(''); + addText(raw); + return $sanitize(html.join('')); + + function addText(text) { + if (!text) { + return; + } + html.push(sanitizeText(text)); + } + + function addLink(url, text) { + html.push(''); + addText(text); + html.push(''); + } }; -}); +}]); })(window, window.angular); diff --git a/webclient/js/angular-sanitize.min.js b/webclient/js/angular-sanitize.min.js index 15bfb59ab7..ce99bba18e 100644 --- a/webclient/js/angular-sanitize.min.js +++ b/webclient/js/angular-sanitize.min.js @@ -1,14 +1,15 @@ /* - AngularJS v1.2.0 - (c) 2010-2012 Google, Inc. http://angularjs.org + AngularJS v1.3.0-rc.1 + (c) 2010-2014 Google, Inc. http://angularjs.org License: MIT */ -(function(m,g,n){'use strict';function h(a){var d={};a=a.split(",");var c;for(c=0;c=c;k--)d.end&&d.end(e[k]);e.length= -c}}var b,f,e=[],l=a;for(e.last=function(){return e[e.length-1]};a;){f=!0;if(e.last()&&v[e.last()])a=a.replace(RegExp("(.*)<\\s*\\/\\s*"+e.last()+"[^>]*>","i"),function(a,b){b=b.replace(F,"$1").replace(G,"$1");d.chars&&d.chars(p(b));return""}),k("",e.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(d.comment&&d.comment(a.substring(4,b)),a=a.substring(b+3),f=!1);else if(w.test(a)){if(b=a.match(w))a=a.replace(b[0],""),f=!1}else if(H.test(a)){if(b=a.match(x))a= -a.substring(b[0].length),b[0].replace(x,k),f=!1}else I.test(a)&&(b=a.match(y))&&(a=a.substring(b[0].length),b[0].replace(y,c),f=!1);f&&(b=a.indexOf("<"),f=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),d.chars&&d.chars(p(f)))}if(a==l)throw J("badparse",a);l=a}k()}function p(a){q.innerHTML=a.replace(//g,">")}function A(a){var d= -!1,c=g.bind(a,a.push);return{start:function(a,b,f){a=g.lowercase(a);!d&&v[a]&&(d=a);d||!0!==B[a]||(c("<"),c(a),g.forEach(b,function(a,b){var d=g.lowercase(b);!0!==L[d]||!0===C[d]&&!a.match(M)||(c(" "),c(b),c('="'),c(z(a)),c('"'))}),c(f?"/>":">"))},end:function(a){a=g.lowercase(a);d||!0!==B[a]||(c(""));a==d&&(d=!1)},chars:function(a){d||c(z(a))}}}var J=g.$$minErr("$sanitize"),y=/^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/,x=/^<\s*\/\s*([\w:-]+)[^>]*>/, -E=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,I=/^]*?)>/i,G=/]/,d=/^mailto:/;return function(c,k){if(!c)return c;var b,f=c,e=[],l=A(e),h,m,n={};g.isDefined(k)&&(n.target=k);for(;b=f.match(a);)h=b[0],b[2]==b[3]&&(h="mailto:"+h),m=b.index,l.chars(f.substr(0,m)),n.href=h,l.start("a",n),l.chars(b[0].replace(d,"")),l.end("a"), -f=f.substring(m+b[0].length);l.chars(f);return e.join("")}})})(window,window.angular); +(function(q,g,r){'use strict';function F(a){var d=[];t(d,g.noop).chars(a);return d.join("")}function m(a){var d={};a=a.split(",");var c;for(c=0;c=c;e--)d.end&&d.end(f[e]);f.length=c}}"string"!==typeof a&&(a=null===a||"undefined"===typeof a?"":""+a);var b,l,f=[],n=a,h;for(f.last=function(){return f[f.length-1]};a;){h="";l=!0;if(f.last()&&y[f.last()])a=a.replace(new RegExp("(.*)<\\s*\\/\\s*"+f.last()+"[^>]*>","i"),function(a,b){b=b.replace(I,"$1").replace(J,"$1");d.chars&&d.chars(s(b));return""}),e("",f.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(d.comment&&d.comment(a.substring(4, +b)),a=a.substring(b+3),l=!1);else if(z.test(a)){if(b=a.match(z))a=a.replace(b[0],""),l=!1}else if(K.test(a)){if(b=a.match(A))a=a.substring(b[0].length),b[0].replace(A,e),l=!1}else L.test(a)&&((b=a.match(B))?(b[4]&&(a=a.substring(b[0].length),b[0].replace(B,c)),l=!1):(h+="<",a=a.substring(1)));l&&(b=a.indexOf("<"),h+=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),d.chars&&d.chars(s(h)))}if(a==n)throw M("badparse",a);n=a}e()}function s(a){if(!a)return"";var d=N.exec(a);a=d[1];var c=d[3];if(d=d[2])p.innerHTML= +d.replace(//g,">")}function t(a,d){var c=!1,e=g.bind(a,a.push);return{start:function(a,l,f){a=g.lowercase(a);!c&&y[a]&&(c=a);c||!0!==D[a]||(e("<"),e(a),g.forEach(l,function(c,f){var k= +g.lowercase(f),l="img"===a&&"src"===k||"background"===k;!0!==Q[k]||!0===E[k]&&!d(c,l)||(e(" "),e(f),e('="'),e(C(c)),e('"'))}),e(f?"/>":">"))},end:function(a){a=g.lowercase(a);c||!0!==D[a]||(e(""));a==c&&(c=!1)},chars:function(a){c||e(C(a))}}}var M=g.$$minErr("$sanitize"),B=/^<((?:[a-zA-Z])[\w:-]*)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*(>?)/,A=/^<\/\s*([\w:-]+)[^>]*>/,H=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,L=/^]*?)>/i,J=/"]/,c=/^mailto:/;return function(e,b){function l(a){a&&k.push(F(a))}function f(a,c){k.push("');l(c);k.push("")} +if(!e)return e;for(var n,h=e,k=[],m,p;n=h.match(d);)m=n[0],n[2]==n[3]&&(m="mailto:"+m),p=n.index,l(h.substr(0,p)),f(m,n[0].replace(c,"")),h=h.substring(p+n[0].length);l(h);return a(k.join(""))}}])})(window,window.angular); //# sourceMappingURL=angular-sanitize.min.js.map diff --git a/webclient/js/angular.js b/webclient/js/angular.js index 217c4036fc..bdc97abb02 100644 --- a/webclient/js/angular.js +++ b/webclient/js/angular.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.2.20 + * @license AngularJS v1.3.0-rc.1 * (c) 2010-2014 Google, Inc. http://angularjs.org * License: MIT */ @@ -30,10 +30,13 @@ * should all be static strings, not variables or general expressions. * * @param {string} module The namespace to use for the new minErr instance. + * @param {function} ErrorConstructor Custom error constructor to be instantiated when returning + * error from returned function, for cases when a particular type of error is useful. * @returns {function(code:string, template:string, ...templateArgs): Error} minErr instance */ -function minErr(module) { +function minErr(module, ErrorConstructor) { + ErrorConstructor = ErrorConstructor || Error; return function () { var code = arguments[0], prefix = '[' + (module ? module + ':' : '') + code + '] ', @@ -68,101 +71,99 @@ function minErr(module) { return match; }); - message = message + '\nhttp://errors.angularjs.org/1.2.20/' + + message = message + '\nhttp://errors.angularjs.org/1.3.0-rc.1/' + (module ? module + '/' : '') + code; for (i = 2; i < arguments.length; i++) { message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' + encodeURIComponent(stringify(arguments[i])); } - - return new Error(message); + return new ErrorConstructor(message); }; } /* We need to tell jshint what variables are being exported */ -/* global - -angular, - -msie, - -jqLite, - -jQuery, - -slice, - -push, - -toString, - -ngMinErr, - -angularModule, - -nodeName_, - -uid, - -VALIDITY_STATE_PROPERTY, - - -lowercase, - -uppercase, - -manualLowercase, - -manualUppercase, - -nodeName_, - -isArrayLike, - -forEach, - -sortedKeys, - -forEachSorted, - -reverseParams, - -nextUid, - -setHashKey, - -extend, - -int, - -inherit, - -noop, - -identity, - -valueFn, - -isUndefined, - -isDefined, - -isObject, - -isString, - -isNumber, - -isDate, - -isArray, - -isFunction, - -isRegExp, - -isWindow, - -isScope, - -isFile, - -isBlob, - -isBoolean, - -trim, - -isElement, - -makeMap, - -map, - -size, - -includes, - -indexOf, - -arrayRemove, - -isLeafNode, - -copy, - -shallowCopy, - -equals, - -csp, - -concat, - -sliceArgs, - -bind, - -toJsonReplacer, - -toJson, - -fromJson, - -toBoolean, - -startingTag, - -tryDecodeURIComponent, - -parseKeyValue, - -toKeyValue, - -encodeUriSegment, - -encodeUriQuery, - -angularInit, - -bootstrap, - -snake_case, - -bindJQuery, - -assertArg, - -assertArgFn, - -assertNotHasOwnProperty, - -getter, - -getBlockElements, - -hasOwnProperty, +/* global angular: true, + msie: true, + jqLite: true, + jQuery: true, + slice: true, + push: true, + toString: true, + ngMinErr: true, + angularModule: true, + uid: true, + REGEX_STRING_REGEXP: true, + VALIDITY_STATE_PROPERTY: true, + lowercase: true, + uppercase: true, + manualLowercase: true, + manualUppercase: true, + nodeName_: true, + isArrayLike: true, + forEach: true, + sortedKeys: true, + forEachSorted: true, + reverseParams: true, + nextUid: true, + setHashKey: true, + extend: true, + int: true, + inherit: true, + noop: true, + identity: true, + valueFn: true, + isUndefined: true, + isDefined: true, + isObject: true, + isString: true, + isNumber: true, + isDate: true, + isArray: true, + isFunction: true, + isRegExp: true, + isWindow: true, + isScope: true, + isFile: true, + isBlob: true, + isBoolean: true, + isPromiseLike: true, + trim: true, + isElement: true, + makeMap: true, + map: true, + size: true, + includes: true, + arrayRemove: true, + isLeafNode: true, + copy: true, + shallowCopy: true, + equals: true, + csp: true, + concat: true, + sliceArgs: true, + bind: true, + toJsonReplacer: true, + toJson: true, + fromJson: true, + startingTag: true, + tryDecodeURIComponent: true, + parseKeyValue: true, + toKeyValue: true, + encodeUriSegment: true, + encodeUriQuery: true, + angularInit: true, + bootstrap: true, + getTestability: true, + snake_case: true, + bindJQuery: true, + assertArg: true, + assertArgFn: true, + assertNotHasOwnProperty: true, + getter: true, + getBlockNodes: true, + hasOwnProperty: true, + createMap: true, */ //////////////////////////////////// @@ -182,6 +183,8 @@ function minErr(module) { *
    */ +var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/; + // The name of a form control's ValidityState property. // This is used so that it's possible for internal tests to create mock ValidityStates. var VALIDITY_STATE_PROPERTY = 'validity'; @@ -247,8 +250,7 @@ var /** holds major version number for IE or NaN for real browsers */ /** @name angular */ angular = window.angular || (window.angular = {}), angularModule, - nodeName_, - uid = ['0', '0', '0']; + uid = 0; /** * IE 11 changed the format of the UserAgent string. @@ -289,9 +291,9 @@ function isArrayLike(obj) { * * @description * Invokes the `iterator` function once for each item in `obj` collection, which can be either an - * object or an array. The `iterator` function is invoked with `iterator(value, key)`, where `value` - * is the value of an object property or an array element and `key` is the object property key or - * array element index. Specifying a `context` for the function is optional. + * object or an array. The `iterator` function is invoked with `iterator(value, key, obj)`, where `value` + * is the value of an object property or an array element, `key` is the object property key or + * array element index and obj is the `obj` itself. Specifying a `context` for the function is optional. * * It is worth noting that `.forEach` does not iterate over inherited properties because it filters * using the `hasOwnProperty` method. @@ -310,26 +312,31 @@ function isArrayLike(obj) { * @param {Object=} context Object to become context (`this`) for the iterator function. * @returns {Object|Array} Reference to `obj`. */ + function forEach(obj, iterator, context) { - var key; + var key, length; if (obj) { if (isFunction(obj)) { for (key in obj) { // Need to check if hasOwnProperty exists, // as on IE8 the result of querySelectorAll is an object without a hasOwnProperty function if (key != 'prototype' && key != 'length' && key != 'name' && (!obj.hasOwnProperty || obj.hasOwnProperty(key))) { - iterator.call(context, obj[key], key); + iterator.call(context, obj[key], key, obj); + } + } + } else if (isArray(obj) || isArrayLike(obj)) { + var isPrimitive = typeof obj !== 'object'; + for (key = 0, length = obj.length; key < length; key++) { + if (isPrimitive || key in obj) { + iterator.call(context, obj[key], key, obj); } } } else if (obj.forEach && obj.forEach !== forEach) { - obj.forEach(iterator, context); - } else if (isArrayLike(obj)) { - for (key = 0; key < obj.length; key++) - iterator.call(context, obj[key], key); + obj.forEach(iterator, context, obj); } else { for (key in obj) { if (obj.hasOwnProperty(key)) { - iterator.call(context, obj[key], key); + iterator.call(context, obj[key], key, obj); } } } @@ -366,33 +373,17 @@ function reverseParams(iteratorFn) { } /** - * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric - * characters such as '012ABC'. The reason why we are not using simply a number counter is that - * the number string gets longer over time, and it can also overflow, where as the nextId - * will grow much slower, it is a string, and it will never overflow. + * A consistent way of creating unique IDs in angular. * - * @returns {string} an unique alpha-numeric string + * Using simple numbers allows us to generate 28.6 million unique ids per second for 10 years before + * we hit number precision issues in JavaScript. + * + * Math.pow(2,53) / 60 / 60 / 24 / 365 / 10 = 28.6M + * + * @returns {number} an unique alpha-numeric string */ function nextUid() { - var index = uid.length; - var digit; - - while(index) { - index--; - digit = uid[index].charCodeAt(0); - if (digit == 57 /*'9'*/) { - uid[index] = 'A'; - return uid.join(''); - } - if (digit == 90 /*'Z'*/) { - uid[index] = '0'; - } else { - uid[index] = String.fromCharCode(digit + 1); - return uid.join(''); - } - } - uid.unshift('0'); - return uid.join(''); + return ++uid; } @@ -426,15 +417,19 @@ function setHashKey(obj, h) { */ function extend(dst) { var h = dst.$$hashKey; - forEach(arguments, function(obj) { - if (obj !== dst) { - forEach(obj, function(value, key) { - dst[key] = value; - }); - } - }); - setHashKey(dst,h); + for (var i = 1, ii = arguments.length; i < ii; i++) { + var obj = arguments[i]; + if (obj) { + var keys = Object.keys(obj); + for (var j = 0, jj = keys.length; j < jj; j++) { + var key = keys[j]; + dst[key] = obj[key]; + } + } + } + + setHashKey(dst, h); return dst; } @@ -532,7 +527,10 @@ function isDefined(value){return typeof value !== 'undefined';} * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Object` but not `null`. */ -function isObject(value){return value != null && typeof value === 'object';} +function isObject(value){ + // http://jsperf.com/isobject4 + return value !== null && typeof value === 'object'; +} /** @@ -594,14 +592,7 @@ function isDate(value) { * @param {*} value Reference to check. * @returns {boolean} True if `value` is an `Array`. */ -var isArray = (function() { - if (!isFunction(Array.isArray)) { - return function(value) { - return toString.call(value) === '[object Array]'; - }; - } - return Array.isArray; -})(); +var isArray = Array.isArray; /** * @ngdoc function @@ -638,7 +629,7 @@ function isRegExp(value) { * @returns {boolean} True if `obj` is a window obj. */ function isWindow(obj) { - return obj && obj.document && obj.location && obj.alert && obj.setInterval; + return obj && obj.window === obj; } @@ -662,19 +653,14 @@ function isBoolean(value) { } -var trim = (function() { - // native trim is way faster: http://jsperf.com/angular-trim-test - // but IE doesn't have it... :-( - // TODO: we should move this into IE/ES5 polyfill - if (!String.prototype.trim) { - return function(value) { - return isString(value) ? value.replace(/^\s\s*/, '').replace(/\s\s*$/, '') : value; - }; - } - return function(value) { - return isString(value) ? value.trim() : value; - }; -})(); +function isPromiseLike(obj) { + return obj && isFunction(obj.then); +} + + +var trim = function(value) { + return isString(value) ? value.trim() : value; +}; /** @@ -707,16 +693,8 @@ function makeMap(str) { } -if (msie < 9) { - nodeName_ = function(element) { - element = element.nodeName ? element : element[0]; - return (element.scopeName && element.scopeName != 'HTML') - ? uppercase(element.scopeName + ':' + element.nodeName) : element.nodeName; - }; -} else { - nodeName_ = function(element) { - return element.nodeName ? element.nodeName : element[0].nodeName; - }; +function nodeName_(element) { + return lowercase(element.nodeName || element[0].nodeName); } @@ -757,20 +735,11 @@ function size(obj, ownPropsOnly) { function includes(array, obj) { - return indexOf(array, obj) != -1; -} - -function indexOf(array, obj) { - if (array.indexOf) return array.indexOf(obj); - - for (var i = 0; i < array.length; i++) { - if (obj === array[i]) return i; - } - return -1; + return Array.prototype.indexOf.call(array, obj) != -1; } function arrayRemove(array, value) { - var index = indexOf(array, value); + var index = array.indexOf(value); if (index >=0) array.splice(index, 1); return value; @@ -778,10 +747,10 @@ function arrayRemove(array, value) { function isLeafNode (node) { if (node) { - switch (node.nodeName) { - case "OPTION": - case "PRE": - case "TITLE": + switch (nodeName_(node)) { + case "option": + case "pre": + case "title": return true; } } @@ -826,7 +795,7 @@ function isLeafNode (node) {
    - *
    - * - * - * - * - * - * - * - *
    {{heading}}
    {{fill}}
    + * ```html + * + * + * + *
    + * {{greeting}} *
    - * - * - * var app = angular.module('multi-bootstrap', []) * - * .controller('BrokenTable', function($scope) { - * $scope.headings = ['One', 'Two', 'Three']; - * $scope.fillings = [[1, 2, 3], ['A', 'B', 'C'], [7, 8, 9]]; - * }); - * - * - * it('should only insert one table cell for each item in $scope.fillings', function() { - * expect(element.all(by.css('td')).count()) - * .toBe(9); - * }); - * - * + * + * + * + * + * ``` * * @param {DOMElement} element DOM element which is the root of angular application. * @param {Array=} modules an array of modules to load into the application. * Each item in the array should be the name of a predefined module or a (DI annotated) * function that will be invoked by the injector as a run block. * See: {@link angular.module modules} + * @param {Object=} config an object for defining configuration options for the application. The + * following keys are supported: + * + * - `strictDi`: disable automatic function annotation for the application. This is meant to + * assist in finding bugs which break minified code. + * * @returns {auto.$injector} Returns the newly created injector for this app. */ -function bootstrap(element, modules) { +function bootstrap(element, modules, config) { + if (!isObject(config)) config = {}; + var defaultConfig = { + strictDi: false + }; + config = extend(defaultConfig, config); var doBootstrap = function() { element = jqLite(element); if (element.injector()) { var tag = (element[0] === document) ? 'document' : startingTag(element); - throw ngMinErr('btstrpd', "App Already Bootstrapped with this Element '{0}'", tag); + //Encode angle brackets to prevent input from being sanitized to empty string #8683 + throw ngMinErr( + 'btstrpd', + "App Already Bootstrapped with this Element '{0}'", + tag.replace(//,'>')); } modules = modules || []; modules.unshift(['$provide', function($provide) { $provide.value('$rootElement', element); }]); + + if (config.debugInfoEnabled) { + // Pushing so that this overrides `debugInfoEnabled` setting defined in user's `modules`. + modules.push(['$compileProvider', function($compileProvider) { + $compileProvider.debugInfoEnabled(true); + }]); + } + modules.unshift('ng'); - var injector = createInjector(modules); - injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', - function(scope, element, compile, injector, animate) { + var injector = createInjector(modules, config.strictDi); + injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', + function bootstrapApply(scope, element, compile, injector) { scope.$apply(function() { element.data('$injector', injector); compile(element)(scope); @@ -1425,8 +1499,14 @@ function bootstrap(element, modules) { return injector; }; + var NG_ENABLE_DEBUG_INFO = /^NG_ENABLE_DEBUG_INFO!/; var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; + if (window && NG_ENABLE_DEBUG_INFO.test(window.name)) { + config.debugInfoEnabled = true; + window.name = window.name.replace(NG_ENABLE_DEBUG_INFO, ''); + } + if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { return doBootstrap(); } @@ -1440,6 +1520,33 @@ function bootstrap(element, modules) { }; } +/** + * @ngdoc function + * @name angular.reloadWithDebugInfo + * @module ng + * @description + * Use this function to reload the current application with debug information turned on. + * This takes precedence over a call to `$compileProvider.debugInfoEnabled(false)`. + * + * See {@link ng.$compileProvider#debugInfoEnabled} for more. + */ +function reloadWithDebugInfo() { + window.name = 'NG_ENABLE_DEBUG_INFO!' + window.name; + window.location.reload(); +} + +/* + * @name angular.getTestability + * @module ng + * @description + * Get the testability service for the instance of Angular on the given + * element. + * @param {DOMElement} element DOM element which is the root of angular application. + */ +function getTestability(rootElement) { + return angular.element(rootElement).injector().get('$$testability'); +} + var SNAKE_CASE_REGEXP = /[A-Z]/g; function snake_case(name, separator) { separator = separator || '_'; @@ -1448,11 +1555,21 @@ function snake_case(name, separator) { }); } +var bindJQueryFired = false; +var skipDestroyOnNextJQueryCleanData; function bindJQuery() { + var originalCleanData; + + if (bindJQueryFired) { + return; + } + // bind to jQuery if present; jQuery = window.jQuery; // Use jQuery if it exists with proper functionality, otherwise default to us. - // Angular 1.2+ requires jQuery 1.7.1+ for on()/off() support. + // Angular 1.2+ requires jQuery 1.7+ for on()/off() support. + // Angular 1.3+ technically requires at least jQuery 2.1+ but it may work with older + // versions. It will not work for sure with jQuery <1.7, though. if (jQuery && jQuery.fn.on) { jqLite = jQuery; extend(jQuery.fn, { @@ -1462,15 +1579,33 @@ function bindJQuery() { injector: JQLitePrototype.injector, inheritedData: JQLitePrototype.inheritedData }); - // Method signature: - // jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) - jqLitePatchJQueryRemove('remove', true, true, false); - jqLitePatchJQueryRemove('empty', false, false, false); - jqLitePatchJQueryRemove('html', false, false, true); + + // All nodes removed from the DOM via various jQuery APIs like .remove() + // are passed through jQuery.cleanData. Monkey-patch this method to fire + // the $destroy event on all removed nodes. + originalCleanData = jQuery.cleanData; + jQuery.cleanData = function(elems) { + var events; + if (!skipDestroyOnNextJQueryCleanData) { + for (var i = 0, elem; (elem = elems[i]) != null; i++) { + events = jQuery._data(elem, "events"); + if (events && events.$destroy) { + jQuery(elem).triggerHandler('$destroy'); + } + } + } else { + skipDestroyOnNextJQueryCleanData = false; + } + originalCleanData(elems); + }; } else { jqLite = JQLite; } + angular.element = jqLite; + + // Prevent double-proxying. + bindJQueryFired = true; } /** @@ -1534,25 +1669,38 @@ function getter(obj, path, bindFnToScope) { /** * Return the DOM siblings between the first and last node in the given array. * @param {Array} array like object - * @returns {DOMElement} object containing the elements + * @returns {jqLite} jqLite collection containing the nodes */ -function getBlockElements(nodes) { - var startNode = nodes[0], - endNode = nodes[nodes.length - 1]; - if (startNode === endNode) { - return jqLite(startNode); - } - - var element = startNode; - var elements = [element]; +function getBlockNodes(nodes) { + // TODO(perf): just check if all items in `nodes` are siblings and if they are return the original + // collection, otherwise update the original collection. + var node = nodes[0]; + var endNode = nodes[nodes.length - 1]; + var blockNodes = [node]; do { - element = element.nextSibling; - if (!element) break; - elements.push(element); - } while (element !== endNode); + node = node.nextSibling; + if (!node) break; + blockNodes.push(node); + } while (node !== endNode); - return jqLite(elements); + return jqLite(blockNodes); +} + + +/** + * Creates a new object without a prototype. This object is useful for lookup without having to + * guard against prototypically inherited properties via hasOwnProperty. + * + * Related micro-benchmarks: + * - http://jsperf.com/object-create2 + * - http://jsperf.com/proto-map-lookup/2 + * - http://jsperf.com/for-in-vs-object-keys2 + * + * @returns {Object} + */ +function createMap() { + return Object.create(null); } /** @@ -1654,22 +1802,26 @@ function setupModuleLoader(window) { /** @type {!Array.>} */ var invokeQueue = []; + /** @type {!Array.} */ + var configBlocks = []; + /** @type {!Array.} */ var runBlocks = []; - var config = invokeLater('$injector', 'invoke'); + var config = invokeLater('$injector', 'invoke', 'push', configBlocks); /** @type {angular.Module} */ var moduleInstance = { // Private state _invokeQueue: invokeQueue, + _configBlocks: configBlocks, _runBlocks: runBlocks, /** * @ngdoc property * @name angular.Module#requires * @module ng - * @returns {Array.} List of module names which must be loaded before this module. + * * @description * Holds the list of modules which the injector will load before the current module is * loaded. @@ -1680,8 +1832,9 @@ function setupModuleLoader(window) { * @ngdoc property * @name angular.Module#name * @module ng - * @returns {string} Name of the module. + * * @description + * Name of the module. */ name: name, @@ -1854,9 +2007,10 @@ function setupModuleLoader(window) { * @param {String=} insertMethod * @returns {angular.Module} */ - function invokeLater(provider, method, insertMethod) { + function invokeLater(provider, method, insertMethod, queue) { + if (!queue) queue = invokeQueue; return function() { - invokeQueue[insertMethod || 'push']([provider, method, arguments]); + queue[insertMethod || 'push']([provider, method, arguments]); return moduleInstance; }; } @@ -1866,81 +2020,90 @@ function setupModuleLoader(window) { } -/* global - angularModule: true, - version: true, +/* global angularModule: true, + version: true, - $LocaleProvider, - $CompileProvider, + $LocaleProvider, + $CompileProvider, - htmlAnchorDirective, - inputDirective, - inputDirective, - formDirective, - scriptDirective, - selectDirective, - styleDirective, - optionDirective, - ngBindDirective, - ngBindHtmlDirective, - ngBindTemplateDirective, - ngClassDirective, - ngClassEvenDirective, - ngClassOddDirective, - ngCspDirective, - ngCloakDirective, - ngControllerDirective, - ngFormDirective, - ngHideDirective, - ngIfDirective, - ngIncludeDirective, - ngIncludeFillContentDirective, - ngInitDirective, - ngNonBindableDirective, - ngPluralizeDirective, - ngRepeatDirective, - ngShowDirective, - ngStyleDirective, - ngSwitchDirective, - ngSwitchWhenDirective, - ngSwitchDefaultDirective, - ngOptionsDirective, - ngTranscludeDirective, - ngModelDirective, - ngListDirective, - ngChangeDirective, - requiredDirective, - requiredDirective, - ngValueDirective, - ngAttributeAliasDirectives, - ngEventDirectives, + htmlAnchorDirective, + inputDirective, + inputDirective, + formDirective, + scriptDirective, + selectDirective, + styleDirective, + optionDirective, + ngBindDirective, + ngBindHtmlDirective, + ngBindTemplateDirective, + ngClassDirective, + ngClassEvenDirective, + ngClassOddDirective, + ngCspDirective, + ngCloakDirective, + ngControllerDirective, + ngFormDirective, + ngHideDirective, + ngIfDirective, + ngIncludeDirective, + ngIncludeFillContentDirective, + ngInitDirective, + ngNonBindableDirective, + ngPluralizeDirective, + ngRepeatDirective, + ngShowDirective, + ngStyleDirective, + ngSwitchDirective, + ngSwitchWhenDirective, + ngSwitchDefaultDirective, + ngOptionsDirective, + ngTranscludeDirective, + ngModelDirective, + ngListDirective, + ngChangeDirective, + patternDirective, + patternDirective, + requiredDirective, + requiredDirective, + minlengthDirective, + minlengthDirective, + maxlengthDirective, + maxlengthDirective, + ngValueDirective, + ngModelOptionsDirective, + ngAttributeAliasDirectives, + ngEventDirectives, - $AnchorScrollProvider, - $AnimateProvider, - $BrowserProvider, - $CacheFactoryProvider, - $ControllerProvider, - $DocumentProvider, - $ExceptionHandlerProvider, - $FilterProvider, - $InterpolateProvider, - $IntervalProvider, - $HttpProvider, - $HttpBackendProvider, - $LocationProvider, - $LogProvider, - $ParseProvider, - $RootScopeProvider, - $QProvider, - $$SanitizeUriProvider, - $SceProvider, - $SceDelegateProvider, - $SnifferProvider, - $TemplateCacheProvider, - $TimeoutProvider, - $$RAFProvider, - $$AsyncCallbackProvider, - $WindowProvider + $AnchorScrollProvider, + $AnimateProvider, + $BrowserProvider, + $CacheFactoryProvider, + $ControllerProvider, + $DocumentProvider, + $ExceptionHandlerProvider, + $FilterProvider, + $InterpolateProvider, + $IntervalProvider, + $HttpProvider, + $HttpBackendProvider, + $LocationProvider, + $LogProvider, + $ParseProvider, + $RootScopeProvider, + $QProvider, + $$QProvider, + $$SanitizeUriProvider, + $SceProvider, + $SceDelegateProvider, + $SnifferProvider, + $TemplateCacheProvider, + $TemplateRequestProvider, + $$TestabilityProvider, + $TimeoutProvider, + $$RAFProvider, + $$AsyncCallbackProvider, + $WindowProvider */ @@ -1959,11 +2122,11 @@ function setupModuleLoader(window) { * - `codeName` – `{string}` – Code name of the release, such as "jiggling-armfat". */ var version = { - full: '1.2.20', // all of these placeholder strings will be replaced by grunt's + full: '1.3.0-rc.1', // all of these placeholder strings will be replaced by grunt's major: 1, // package task - minor: 2, - dot: 20, - codeName: 'accidental-beautification' + minor: 3, + dot: 0, + codeName: 'backyard-atomicity' }; @@ -1976,11 +2139,11 @@ function publishExternalAPI(angular){ 'element': jqLite, 'forEach': forEach, 'injector': createInjector, - 'noop':noop, - 'bind':bind, + 'noop': noop, + 'bind': bind, 'toJson': toJson, 'fromJson': fromJson, - 'identity':identity, + 'identity': identity, 'isUndefined': isUndefined, 'isDefined': isDefined, 'isString': isString, @@ -1994,8 +2157,11 @@ function publishExternalAPI(angular){ 'lowercase': lowercase, 'uppercase': uppercase, 'callbacks': {counter: 0}, + 'getTestability': getTestability, '$$minErr': minErr, - '$$csp': csp + '$$csp': csp, + 'reloadWithDebugInfo': reloadWithDebugInfo, + '$$hasClass': jqLiteHasClass }); angularModule = setupModuleLoader(window); @@ -2047,9 +2213,16 @@ function publishExternalAPI(angular){ ngModel: ngModelDirective, ngList: ngListDirective, ngChange: ngChangeDirective, + pattern: patternDirective, + ngPattern: patternDirective, required: requiredDirective, ngRequired: requiredDirective, - ngValue: ngValueDirective + minlength: minlengthDirective, + ngMinlength: minlengthDirective, + maxlength: maxlengthDirective, + ngMaxlength: maxlengthDirective, + ngValue: ngValueDirective, + ngModelOptions: ngModelOptionsDirective }). directive({ ngInclude: ngIncludeFillContentDirective @@ -2074,10 +2247,13 @@ function publishExternalAPI(angular){ $parse: $ParseProvider, $rootScope: $RootScopeProvider, $q: $QProvider, + $$q: $$QProvider, $sce: $SceProvider, $sceDelegate: $SceDelegateProvider, $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, + $templateRequest: $TemplateRequestProvider, + $$testability: $$TestabilityProvider, $timeout: $TimeoutProvider, $window: $WindowProvider, $$rAF: $$RAFProvider, @@ -2087,12 +2263,11 @@ function publishExternalAPI(angular){ ]); } -/* global - - -JQLitePrototype, - -addEventListenerFn, - -removeEventListenerFn, - -BOOLEAN_ATTR +/* global JQLitePrototype: true, + addEventListenerFn: true, + removeEventListenerFn: true, + BOOLEAN_ATTR: true, + ALIASED_ATTR: true, */ ////////////////////////////////// @@ -2134,6 +2309,7 @@ function publishExternalAPI(angular){ * - [`contents()`](http://api.jquery.com/contents/) * - [`css()`](http://api.jquery.com/css/) * - [`data()`](http://api.jquery.com/data/) + * - [`detach()`](http://api.jquery.com/detach/) * - [`empty()`](http://api.jquery.com/empty/) * - [`eq()`](http://api.jquery.com/eq/) * - [`find()`](http://api.jquery.com/find/) - Limited to lookups by tag name @@ -2189,17 +2365,17 @@ JQLite.expando = 'ng339'; var jqCache = JQLite.cache = {}, jqId = 1, - addEventListenerFn = (window.document.addEventListener - ? function(element, type, fn) {element.addEventListener(type, fn, false);} - : function(element, type, fn) {element.attachEvent('on' + type, fn);}), - removeEventListenerFn = (window.document.removeEventListener - ? function(element, type, fn) {element.removeEventListener(type, fn, false); } - : function(element, type, fn) {element.detachEvent('on' + type, fn); }); + addEventListenerFn = function(element, type, fn) { + element.addEventListener(type, fn, false); + }, + removeEventListenerFn = function(element, type, fn) { + element.removeEventListener(type, fn, false); + }; /* * !!! This is an undocumented "private" function !!! */ -var jqData = JQLite._data = function(node) { +JQLite._data = function(node) { //jQuery always returns an object on cache miss return this.cache[node[this.expando]] || {}; }; @@ -2209,6 +2385,7 @@ function jqNextId() { return ++jqId; } var SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g; var MOZ_HACK_REGEXP = /^moz([A-Z])/; +var MOUSE_EVENT_MAP= { mouseleave : "mouseout", mouseenter : "mouseover"}; var jqLiteMinErr = minErr('jqLite'); /** @@ -2224,49 +2401,6 @@ function camelCase(name) { replace(MOZ_HACK_REGEXP, 'Moz$1'); } -///////////////////////////////////////////// -// jQuery mutation patch -// -// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a -// $destroy event on all DOM nodes being removed. -// -///////////////////////////////////////////// - -function jqLitePatchJQueryRemove(name, dispatchThis, filterElems, getterIfNoArguments) { - var originalJqFn = jQuery.fn[name]; - originalJqFn = originalJqFn.$original || originalJqFn; - removePatch.$original = originalJqFn; - jQuery.fn[name] = removePatch; - - function removePatch(param) { - // jshint -W040 - var list = filterElems && param ? [this.filter(param)] : [this], - fireEvent = dispatchThis, - set, setIndex, setLength, - element, childIndex, childLength, children; - - if (!getterIfNoArguments || param != null) { - while(list.length) { - set = list.shift(); - for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) { - element = jqLite(set[setIndex]); - if (fireEvent) { - element.triggerHandler('$destroy'); - } else { - fireEvent = !fireEvent; - } - for(childIndex = 0, childLength = (children = element.children()).length; - childIndex < childLength; - childIndex++) { - list.push(jQuery(children[childIndex])); - } - } - } - } - return originalJqFn.apply(this, arguments); - } -} - var SINGLE_TAG_REGEXP = /^<(\w+)\s*\/?>(?:<\/\1>|)$/; var HTML_REGEXP = /<|&#?\w+;/; var TAG_NAME_REGEXP = /<([\w:]+)/; @@ -2286,26 +2420,32 @@ wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; + function jqLiteIsTextNode(html) { return !HTML_REGEXP.test(html); } +function jqLiteAcceptsData(node) { + // The window object can accept data but has no nodeType + // Otherwise we are only interested in elements (1) and documents (9) + var nodeType = node.nodeType; + return nodeType === 1 || !nodeType || nodeType === 9; +} + function jqLiteBuildFragment(html, context) { - var elem, tmp, tag, wrap, + var tmp, tag, wrap, fragment = context.createDocumentFragment(), - nodes = [], i, j, jj; + nodes = [], i; if (jqLiteIsTextNode(html)) { // Convert non-html into a text node nodes.push(context.createTextNode(html)); } else { - tmp = fragment.appendChild(context.createElement('div')); // Convert html into DOM nodes + tmp = tmp || fragment.appendChild(context.createElement("div")); tag = (TAG_NAME_REGEXP.exec(html) || ["", ""])[1].toLowerCase(); wrap = wrapMap[tag] || wrapMap._default; - tmp.innerHTML = '
     
    ' + - wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1>") + wrap[2]; - tmp.removeChild(tmp.firstChild); + tmp.innerHTML = wrap[1] + html.replace(XHTML_TAG_REGEXP, "<$1>") + wrap[2]; // Descend through wrappers to the right content i = wrap[0]; @@ -2313,7 +2453,7 @@ function jqLiteBuildFragment(html, context) { tmp = tmp.lastChild; } - for (j=0, jj=tmp.childNodes.length; j 1)) { + eventFns = shallowCopy(eventFns); + } - forEach(eventHandlersCopy, function(fn) { - fn.call(element, event); - }); - - // Remove monkey-patched methods (IE), - // as they would cause memory leaks in IE8. - if (msie <= 8) { - // IE7/8 does not allow to delete property on native object - event.preventDefault = null; - event.stopPropagation = null; - event.isDefaultPrevented = null; - } else { - // It shouldn't affect normal browsers (native methods are defined on prototype). - delete event.preventDefault; - delete event.stopPropagation; - delete event.isDefaultPrevented; + for (var i = 0; i < eventFnsLength; i++) { + eventFns[i].call(element, event); } }; + + // TODO: this is a hack for angularMocks/clearDataCache that makes it possible to deregister all + // events on `element` eventHandler.elem = element; return eventHandler; } @@ -2849,68 +2992,56 @@ function createEventHandler(element, events) { forEach({ removeData: jqLiteRemoveData, - dealoc: jqLiteDealoc, - - on: function onFn(element, type, fn, unsupported){ + on: function jqLiteOn(element, type, fn, unsupported){ if (isDefined(unsupported)) throw jqLiteMinErr('onargs', 'jqLite#on() does not support the `selector` or `eventData` parameters'); - var events = jqLiteExpandoStore(element, 'events'), - handle = jqLiteExpandoStore(element, 'handle'); + // Do not add event handlers to non-elements because they will not be cleaned up. + if (!jqLiteAcceptsData(element)) { + return; + } - if (!events) jqLiteExpandoStore(element, 'events', events = {}); - if (!handle) jqLiteExpandoStore(element, 'handle', handle = createEventHandler(element, events)); + var expandoStore = jqLiteExpandoStore(element, true); + var events = expandoStore.events; + var handle = expandoStore.handle; - forEach(type.split(' '), function(type){ + if (!handle) { + handle = expandoStore.handle = createEventHandler(element, events); + } + + // http://jsperf.com/string-indexof-vs-split + var types = type.indexOf(' ') >= 0 ? type.split(' ') : [type]; + var i = types.length; + + while (i--) { + type = types[i]; var eventFns = events[type]; if (!eventFns) { - if (type == 'mouseenter' || type == 'mouseleave') { - var contains = document.body.contains || document.body.compareDocumentPosition ? - function( a, b ) { - // jshint bitwise: false - var adown = a.nodeType === 9 ? a.documentElement : a, - bup = b && b.parentNode; - return a === bup || !!( bup && bup.nodeType === 1 && ( - adown.contains ? - adown.contains( bup ) : - a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 - )); - } : - function( a, b ) { - if ( b ) { - while ( (b = b.parentNode) ) { - if ( b === a ) { - return true; - } - } - } - return false; - }; - - events[type] = []; + events[type] = []; + if (type === 'mouseenter' || type === 'mouseleave') { // Refer to jQuery's implementation of mouseenter & mouseleave // Read about mouseenter and mouseleave: // http://www.quirksmode.org/js/events_mouse.html#link8 - var eventmap = { mouseleave : "mouseout", mouseenter : "mouseover"}; - onFn(element, eventmap[type], function(event) { + jqLiteOn(element, MOUSE_EVENT_MAP[type], function(event) { var target = this, related = event.relatedTarget; // For mousenter/leave call the handler if related is outside the target. // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !contains(target, related)) ){ + if ( !related || (related !== target && !target.contains(related)) ){ handle(event, type); } }); } else { - addEventListenerFn(element, type, handle); - events[type] = []; + if (type !== '$destroy') { + addEventListenerFn(element, type, handle); + } } eventFns = events[type]; } eventFns.push(fn); - }); + } }, off: jqLiteOff, @@ -2955,11 +3086,15 @@ forEach({ }, append: function(element, node) { - forEach(new JQLite(node), function(child){ - if (element.nodeType === 1 || element.nodeType === 11) { - element.appendChild(child); - } - }); + var nodeType = element.nodeType; + if (nodeType !== 1 && nodeType !== 11) return; + + node = new JQLite(node); + + for (var i = 0, ii = node.length; i < ii; i++) { + var child = node[i]; + element.appendChild(child); + } }, prepend: function(element, node) { @@ -2972,7 +3107,7 @@ forEach({ }, wrap: function(element, wrapNode) { - wrapNode = jqLite(wrapNode)[0]; + wrapNode = jqLite(wrapNode).eq(0).clone()[0]; var parent = element.parentNode; if (parent) { parent.replaceChild(wrapNode, element); @@ -2980,18 +3115,21 @@ forEach({ wrapNode.appendChild(element); }, - remove: function(element) { - jqLiteDealoc(element); - var parent = element.parentNode; - if (parent) parent.removeChild(element); + remove: jqLiteRemove, + + detach: function(element) { + jqLiteRemove(element, true); }, after: function(element, newElement) { var index = element, parent = element.parentNode; - forEach(new JQLite(newElement), function(node){ + newElement = new JQLite(newElement); + + for (var i = 0, ii = newElement.length; i < ii; i++) { + var node = newElement[i]; parent.insertBefore(node, index.nextSibling); index = node; - }); + } }, addClass: jqLiteAddClass, @@ -3015,16 +3153,7 @@ forEach({ }, next: function(element) { - if (element.nextElementSibling) { - return element.nextElementSibling; - } - - // IE8 doesn't have nextElementSibling - var elm = element.nextSibling; - while (elm != null && elm.nodeType !== 1) { - elm = elm.nextSibling; - } - return elm; + return element.nextElementSibling; }, find: function(element, selector) { @@ -3037,19 +3166,39 @@ forEach({ clone: jqLiteClone, - triggerHandler: function(element, eventName, eventData) { - var eventFns = (jqLiteExpandoStore(element, 'events') || {})[eventName]; + triggerHandler: function(element, event, extraParameters) { - eventData = eventData || []; + var dummyEvent, eventFnsCopy, handlerArgs; + var eventName = event.type || event; + var expandoStore = jqLiteExpandoStore(element); + var events = expandoStore && expandoStore.events; + var eventFns = events && events[eventName]; - var event = [{ - preventDefault: noop, - stopPropagation: noop - }]; + if (eventFns) { - forEach(eventFns, function(fn) { - fn.apply(element, event.concat(eventData)); - }); + // Create a dummy event to pass to the handlers + dummyEvent = { + preventDefault: function() { this.defaultPrevented = true; }, + isDefaultPrevented: function() { return this.defaultPrevented === true; }, + stopPropagation: noop, + type: eventName, + target: element + }; + + // If a custom event was provided then extend our dummy event with it + if (event.type) { + dummyEvent = extend(dummyEvent, event); + } + + // Copy event handlers in case event handlers array is modified during execution. + eventFnsCopy = shallowCopy(eventFns); + handlerArgs = extraParameters ? [dummyEvent].concat(extraParameters) : [dummyEvent]; + + forEach(eventFnsCopy, function(fn) { + fn.apply(element, handlerArgs); + }); + + } } }, function(fn, name){ /** @@ -3057,7 +3206,8 @@ forEach({ */ JQLite.prototype[name] = function(arg1, arg2, arg3) { var value; - for(var i=0; i < this.length; i++) { + + for(var i = 0, ii = this.length; i < ii; i++) { if (isUndefined(value)) { value = fn(this[i], arg1, arg2, arg3); if (isDefined(value)) { @@ -3089,21 +3239,23 @@ forEach({ * The resulting string key is in 'type:hashKey' format. */ function hashKey(obj, nextUidFn) { - var objType = typeof obj, - key; + var key = obj && obj.$$hashKey; - if (objType == 'function' || (objType == 'object' && obj !== null)) { - if (typeof (key = obj.$$hashKey) == 'function') { - // must invoke on object to keep the right this + if (key) { + if (typeof key === 'function') { key = obj.$$hashKey(); - } else if (key === undefined) { - key = obj.$$hashKey = (nextUidFn || nextUid)(); } - } else { - key = obj; + return key; } - return objType + ':' + key; + var objType = typeof obj; + if (objType == 'function' || (objType == 'object' && obj !== null)) { + key = obj.$$hashKey = objType + ':' + (nextUidFn || nextUid)(); + } else { + key = objType + ':' + obj; + } + + return key; } /** @@ -3170,7 +3322,7 @@ HashMap.prototype = { * * // use the injector to kick off your application * // use the type inference to auto inject arguments, or use implicit injection - * $injector.invoke(function($rootScope, $compile, $document){ + * $injector.invoke(function($rootScope, $compile, $document) { * $compile($document)($rootScope); * $rootScope.$digest(); * }); @@ -3213,7 +3365,19 @@ var FN_ARG_SPLIT = /,/; var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; var $injectorMinErr = minErr('$injector'); -function annotate(fn) { + +function anonFn(fn) { + // For anonymous functions, showing at the very least the function signature can help in + // debugging. + var fnText = fn.toString().replace(STRIP_COMMENTS, ''), + args = fnText.match(FN_ARGS); + if (args) { + return 'function(' + (args[1] || '').replace(/[\s\r\n]+/, ' ') + ')'; + } + return 'fn'; +} + +function annotate(fn, strictDi, name) { var $inject, fnText, argDecl, @@ -3223,10 +3387,17 @@ function annotate(fn) { if (!($inject = fn.$inject)) { $inject = []; if (fn.length) { + if (strictDi) { + if (!isString(name) || !name) { + name = fn.name || anonFn(fn); + } + throw $injectorMinErr('strictdi', + '{0} is not using explicit annotation and cannot be invoked in strict mode', name); + } fnText = fn.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); - forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ - arg.replace(FN_ARG, function(all, underscore, name){ + forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg) { + arg.replace(FN_ARG, function(all, underscore, name) { $inject.push(name); }); }); @@ -3261,7 +3432,7 @@ function annotate(fn) { * ```js * var $injector = angular.injector(); * expect($injector.get('$injector')).toBe($injector); - * expect($injector.invoke(function($injector){ + * expect($injector.invoke(function($injector) { * return $injector; * }).toBe($injector); * ``` @@ -3734,7 +3905,8 @@ function annotate(fn) { */ -function createInjector(modulesToLoad) { +function createInjector(modulesToLoad, strictDi) { + strictDi = (strictDi === true); var INSTANTIATING = {}, providerSuffix = 'Provider', path = [], @@ -3757,7 +3929,7 @@ function createInjector(modulesToLoad) { instanceInjector = (instanceCache.$injector = createInternalInjector(instanceCache, function(servicename) { var provider = providerInjector.get(servicename + providerSuffix); - return instanceInjector.invoke(provider.$get, provider); + return instanceInjector.invoke(provider.$get, provider, undefined, servicename); })); @@ -3820,22 +3992,27 @@ function createInjector(modulesToLoad) { // Module Loading //////////////////////////////////// function loadModules(modulesToLoad){ - var runBlocks = [], moduleFn, invokeQueue, i, ii; + var runBlocks = [], moduleFn; forEach(modulesToLoad, function(module) { if (loadedModules.get(module)) return; loadedModules.put(module, true); + function runInvokeQueue(queue) { + var i, ii; + for(i = 0, ii = queue.length; i < ii; i++) { + var invokeArgs = queue[i], + provider = providerInjector.get(invokeArgs[0]); + + provider[invokeArgs[1]].apply(provider, invokeArgs[2]); + } + } + try { if (isString(module)) { moduleFn = angularModule(module); runBlocks = runBlocks.concat(loadModules(moduleFn.requires)).concat(moduleFn._runBlocks); - - for(invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) { - var invokeArgs = invokeQueue[i], - provider = providerInjector.get(invokeArgs[0]); - - provider[invokeArgs[1]].apply(provider, invokeArgs[2]); - } + runInvokeQueue(moduleFn._invokeQueue); + runInvokeQueue(moduleFn._configBlocks); } else if (isFunction(module)) { runBlocks.push(providerInjector.invoke(module)); } else if (isArray(module)) { @@ -3891,9 +4068,14 @@ function createInjector(modulesToLoad) { } } - function invoke(fn, self, locals){ + function invoke(fn, self, locals, serviceName) { + if (typeof locals === 'string') { + serviceName = locals; + locals = null; + } + var args = [], - $inject = annotate(fn), + $inject = annotate(fn, strictDi, serviceName), length, i, key; @@ -3918,7 +4100,7 @@ function createInjector(modulesToLoad) { return fn.apply(self, args); } - function instantiate(Type, locals) { + function instantiate(Type, locals, serviceName) { var Constructor = function() {}, instance, returnedValue; @@ -3926,7 +4108,7 @@ function createInjector(modulesToLoad) { // e.g. someModule.factory('greeter', ['$window', function(renamed$window) {}]); Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype; instance = new Constructor(); - returnedValue = invoke(Type, instance, locals); + returnedValue = invoke(Type, instance, locals, serviceName); return isObject(returnedValue) || isFunction(returnedValue) ? returnedValue : instance; } @@ -3943,6 +4125,8 @@ function createInjector(modulesToLoad) { } } +createInjector.$$annotate = annotate; + /** * @ngdoc service * @name $anchorScroll @@ -3960,24 +4144,26 @@ function createInjector(modulesToLoad) { * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. * * @example - + -
    +
    Go to bottom You're at the bottom!
    - function ScrollCtrl($scope, $location, $anchorScroll) { - $scope.gotoBottom = function (){ - // set the location.hash to the id of - // the element you wish to scroll to. - $location.hash('bottom'); + angular.module('anchorScrollExample', []) + .controller('ScrollController', ['$scope', '$location', '$anchorScroll', + function ($scope, $location, $anchorScroll) { + $scope.gotoBottom = function() { + // set the location.hash to the id of + // the element you wish to scroll to. + $location.hash('bottom'); - // call $anchorScroll() - $anchorScroll(); - }; - } + // call $anchorScroll() + $anchorScroll(); + }; + }]); #scrollArea { @@ -4010,7 +4196,7 @@ function $AnchorScrollProvider() { function getFirstAnchor(list) { var result = null; forEach(list, function(element) { - if (!result && lowercase(element.nodeName) === 'a') result = element; + if (!result && nodeName_(element) === 'a') result = element; }); return result; } @@ -4125,10 +4311,19 @@ var $AnimateProvider = ['$provide', function($provide) { return this.$$classNameFilter; }; - this.$get = ['$timeout', '$$asyncCallback', function($timeout, $$asyncCallback) { + this.$get = ['$$q', '$$asyncCallback', function($$q, $$asyncCallback) { - function async(fn) { - fn && $$asyncCallback(fn); + var currentDefer; + function asyncPromise() { + // only serve one instance of a promise in order to save CPU cycles + if (!currentDefer) { + currentDefer = $$q.defer(); + $$asyncCallback(function() { + currentDefer.resolve(); + currentDefer = null; + }); + } + return currentDefer.promise; } /** @@ -4155,26 +4350,20 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#enter * @kind function - * @description Inserts the element into the DOM either after the `after` element or within - * the `parent` element. Once complete, the done() callback will be fired (if provided). + * @description Inserts the element into the DOM either after the `after` element or + * as the first child within the `parent` element. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be inserted into the DOM * @param {DOMElement} parent the parent element which will append the element as * a child (if the after element is not present) * @param {DOMElement} after the sibling element which will append the element * after itself - * @param {Function=} done callback function that will be called after the element has been - * inserted into the DOM + * @return {Promise} the animation callback promise */ - enter : function(element, parent, after, done) { - if (after) { - after.after(element); - } else { - if (!parent || !parent[0]) { - parent = after.parent(); - } - parent.append(element); - } - async(done); + enter : function(element, parent, after) { + after ? after.after(element) + : parent.prepend(element); + return asyncPromise(); }, /** @@ -4182,15 +4371,14 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#leave * @kind function - * @description Removes the element from the DOM. Once complete, the done() callback will be - * fired (if provided). + * @description Removes the element from the DOM. When the function is called a promise + * is returned that will be resolved at a later time. * @param {DOMElement} element the element which will be removed from the DOM - * @param {Function=} done callback function that will be called after the element has been - * removed from the DOM + * @return {Promise} the animation callback promise */ - leave : function(element, done) { + leave : function(element) { element.remove(); - async(done); + return asyncPromise(); }, /** @@ -4199,8 +4387,8 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#move * @kind function * @description Moves the position of the provided element within the DOM to be placed - * either after the `after` element or inside of the `parent` element. Once complete, the - * done() callback will be fired (if provided). + * either after the `after` element or inside of the `parent` element. When the function + * is called a promise is returned that will be resolved at a later time. * * @param {DOMElement} element the element which will be moved around within the * DOM @@ -4208,13 +4396,12 @@ var $AnimateProvider = ['$provide', function($provide) { * inserted into (if the after element is not present) * @param {DOMElement} after the sibling element where the element will be * positioned next to - * @param {Function=} done the callback function (if provided) that will be fired after the - * element has been moved to its new position + * @return {Promise} the animation callback promise */ - move : function(element, parent, after, done) { + move : function(element, parent, after) { // Do not remove element before insert. Removing will cause data associated with the // element to be dropped. Insert will implicitly do the remove. - this.enter(element, parent, after, done); + return this.enter(element, parent, after); }, /** @@ -4222,22 +4409,21 @@ var $AnimateProvider = ['$provide', function($provide) { * @ngdoc method * @name $animate#addClass * @kind function - * @description Adds the provided className CSS class value to the provided element. Once - * complete, the done() callback will be fired (if provided). + * @description Adds the provided className CSS class value to the provided element. + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * added to it * @param {string} className the CSS class which will be added to the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been added to the element + * @return {Promise} the animation callback promise */ - addClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; + addClass : function(element, className) { + className = !isString(className) + ? (isArray(className) ? className.join(' ') : '') + : className; forEach(element, function (element) { jqLiteAddClass(element, className); }); - async(done); + return asyncPromise(); }, /** @@ -4246,21 +4432,20 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#removeClass * @kind function * @description Removes the provided className CSS class value from the provided element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have the className value * removed from it * @param {string} className the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * className value has been removed from the element + * @return {Promise} the animation callback promise */ - removeClass : function(element, className, done) { - className = isString(className) ? - className : - isArray(className) ? className.join(' ') : ''; + removeClass : function(element, className) { + className = !isString(className) + ? (isArray(className) ? className.join(' ') : '') + : className; forEach(element, function (element) { jqLiteRemoveClass(element, className); }); - async(done); + return asyncPromise(); }, /** @@ -4269,23 +4454,21 @@ var $AnimateProvider = ['$provide', function($provide) { * @name $animate#setClass * @kind function * @description Adds and/or removes the given CSS classes to and from the element. - * Once complete, the done() callback will be fired (if provided). + * When the function is called a promise is returned that will be resolved at a later time. * @param {DOMElement} element the element which will have its CSS classes changed * removed from it * @param {string} add the CSS classes which will be added to the element * @param {string} remove the CSS class which will be removed from the element - * @param {Function=} done the callback function (if provided) that will be fired after the - * CSS classes have been set on the element + * @return {Promise} the animation callback promise */ - setClass : function(element, add, remove, done) { - forEach(element, function (element) { - jqLiteAddClass(element, add); - jqLiteRemoveClass(element, remove); - }); - async(done); + setClass : function(element, add, remove) { + this.addClass(element, add); + this.removeClass(element, remove); + return asyncPromise(); }, - enabled : noop + enabled : noop, + cancel : noop }; }]; }]; @@ -4534,6 +4717,13 @@ function Browser(window, document, $log, $sniffer) { return callback; }; + /** + * Checks whether the url has changed outside of Angular. + * Needs to be exported to be able to check for changes that have been done in sync, + * as hashchange/popstate events fire in async. + */ + self.$$checkUrlChange = fireUrlChange; + ////////////////////////////////////////////////////////////// // Misc API ////////////////////////////////////////////////////////////// @@ -4580,16 +4770,15 @@ function Browser(window, document, $log, $sniffer) { * @returns {Object} Hash of all cookies (if called without any parameter) */ self.cookies = function(name, value) { - /* global escape: false, unescape: false */ var cookieLength, cookieArray, cookie, i, index; if (name) { if (value === undefined) { - rawDocument.cookie = escape(name) + "=;path=" + cookiePath + + rawDocument.cookie = encodeURIComponent(name) + "=;path=" + cookiePath + ";expires=Thu, 01 Jan 1970 00:00:00 GMT"; } else { if (isString(value)) { - cookieLength = (rawDocument.cookie = escape(name) + '=' + escape(value) + + cookieLength = (rawDocument.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + ';path=' + cookiePath).length + 1; // per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum: @@ -4613,12 +4802,12 @@ function Browser(window, document, $log, $sniffer) { cookie = cookieArray[i]; index = cookie.indexOf('='); if (index > 0) { //ignore nameless cookies - name = unescape(cookie.substring(0, index)); + name = decodeURIComponent(cookie.substring(0, index)); // the first value that is seen for a cookie is the most // specific one. values for the same cookie name that // follow are for less specific paths. if (lastCookies[name] === undefined) { - lastCookies[name] = unescape(cookie.substring(index + 1)); + lastCookies[name] = decodeURIComponent(cookie.substring(index + 1)); } } } @@ -4750,8 +4939,10 @@ function $BrowserProvider(){ $scope.keys = []; $scope.cache = $cacheFactory('cacheId'); $scope.put = function(key, value) { - $scope.cache.put(key, value); - $scope.keys.push(key); + if ($scope.cache.get(key) === undefined) { + $scope.keys.push(key); + } + $scope.cache.put(key, value === undefined ? null : value); }; }]); @@ -5191,6 +5382,13 @@ function $TemplateCacheProvider() { * The directive definition object provides instructions to the {@link ng.$compile * compiler}. The attributes are: * + * #### `multiElement` + * When this property is set to true, the HTML compiler will collect DOM nodes between + * nodes with the attributes `directive-name-start` and `directive-name-end`, and group them + * together as the directive elements. It is recomended that this feature be used on directives + * which are not strictly behavioural (such as {@link api/ng.directive:ngClick ngClick}), and which + * do not manipulate or replace child nodes (such as {@link api/ng.directive:ngInclude ngInclude}). + * * #### `priority` * When there are multiple directives defined on a single DOM element, sometimes it * is necessary to specify the order in which the directives are applied. The `priority` is used @@ -5248,6 +5446,10 @@ function $TemplateCacheProvider() { * by calling the `localFn` as `localFn({amount: 22})`. * * + * #### `bindToController` + * When an isolate scope is used for a component (see above), and `controllerAs` is used, `bindToController` will + * allow a component to have its properties bound to the controller, rather than to scope. When the controller + * is instantiated, the initial values of the isolate scope bindings are already available. * * #### `controller` * Controller constructor function. The controller is instantiated before the @@ -5258,9 +5460,18 @@ function $TemplateCacheProvider() { * * `$scope` - Current scope associated with the element * * `$element` - Current element * * `$attrs` - Current attributes object for the element - * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. - * `function([scope], cloneLinkingFn)`. + * * `$transclude` - A transclude linking function pre-bound to the correct transclusion scope: + * `function([scope], cloneLinkingFn, futureParentElement)`. + * * `scope`: optional argument to override the scope. + * * `cloneLinkingFn`: optional argument to create clones of the original transcluded content. + * * `futureParentElement`: + * * defines the parent to which the `cloneLinkingFn` will add the cloned elements. + * * default: `$element.parent()` resp. `$element` for `transclude:'element'` resp. `transclude:true`. + * * only needed for transcludes that are allowed to contain non html elements (e.g. SVG elements) + * and when the `cloneLinkinFn` is passed, + * as those elements need to created and cloned in a special way when they are defined outside their + * usual containers (e.g. like ``). + * * See also the `directive.templateNamespace` property. * * * #### `require` @@ -5271,9 +5482,9 @@ function $TemplateCacheProvider() { * * * (no prefix) - Locate the required controller on the current element. Throw an error if not found. * * `?` - Attempt to locate the required controller or pass `null` to the `link` fn if not found. - * * `^` - Locate the required controller by searching the element's parents. Throw an error if not found. - * * `?^` - Attempt to locate the required controller by searching the element's parents or pass `null` to the - * `link` fn if not found. + * * `^` - Locate the required controller by searching the element and its parents. Throw an error if not found. + * * `?^` - Attempt to locate the required controller by searching the element and its parents or pass + * `null` to the `link` fn if not found. * * * #### `controllerAs` @@ -5284,29 +5495,51 @@ function $TemplateCacheProvider() { * * #### `restrict` * String of subset of `EACM` which restricts the directive to a specific directive - * declaration style. If omitted, the default (attributes only) is used. + * declaration style. If omitted, the defaults (elements and attributes) are used. * - * * `E` - Element name: `` + * * `E` - Element name (default): `` * * `A` - Attribute (default): `
    ` * * `C` - Class: `
    ` * * `M` - Comment: `` * * - * #### `template` - * replace the current element with the contents of the HTML. The replacement process - * migrates all of the attributes / classes from the old element to the new one. See the - * {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive - * Directives Guide} for an example. + * #### `templateNamespace` + * String representing the document type used by the markup in the template. + * AngularJS needs this information as those elements need to be created and cloned + * in a special way when they are defined outside their usual containers like `` and ``. * - * You can specify `template` as a string representing the template or as a function which takes - * two arguments `tElement` and `tAttrs` (described in the `compile` function api below) and - * returns a string value representing the template. + * * `html` - All root nodes in the template are HTML. Root nodes may also be + * top-level elements such as `` or ``. + * * `svg` - The root nodes in the template are SVG elements (excluding ``). + * * `math` - The root nodes in the template are MathML elements (excluding ``). + * + * If no `templateNamespace` is specified, then the namespace is considered to be `html`. + * + * #### `template` + * HTML markup that may: + * * Replace the contents of the directive's element (default). + * * Replace the directive's element itself (if `replace` is true - DEPRECATED). + * * Wrap the contents of the directive's element (if `transclude` is true). + * + * Value may be: + * + * * A string. For example `
    {{delete_str}}
    `. + * * A function which takes two arguments `tElement` and `tAttrs` (described in the `compile` + * function api below) and returns a string value. * * * #### `templateUrl` - * Same as `template` but the template is loaded from the specified URL. Because - * the template loading is asynchronous the compilation/linking is suspended until the template - * is loaded. + * This is similar to `template` but the template is loaded from the specified URL, asynchronously. + * + * Because template loading is asynchronous the compiler will suspend compilation of directives on that element + * for later when the template has been resolved. In the meantime it will continue to compile and link + * sibling and parent elements as though this element had not contained any directives. + * + * The compiler does not suspend the entire compilation to wait for templates to be loaded because this + * would result in the whole app "stalling" until all templates are loaded asynchronously - even in the + * case when only one deeply nested directive has `templateUrl`. + * + * Template loading is asynchronous even if the template has been preloaded into the {@link $templateCache} * * You can specify `templateUrl` as a string representing the URL or as a function which takes two * arguments `tElement` and `tAttrs` (described in the `compile` function api below) and returns @@ -5314,12 +5547,19 @@ function $TemplateCacheProvider() { * api/ng.$sce#getTrustedResourceUrl $sce.getTrustedResourceUrl}. * * - * #### `replace` ([*DEPRECATED*!], will be removed in next major release) - * specify where the template should be inserted. Defaults to `false`. + * #### `replace` ([*DEPRECATED*!], will be removed in next major release - i.e. v2.0) + * specify what the template should replace. Defaults to `false`. * - * * `true` - the template will replace the current element. - * * `false` - the template will replace the contents of the current element. + * * `true` - the template will replace the directive's element. + * * `false` - the template will replace the contents of the directive's element. * + * The replacement process migrates all of the attributes / classes from the old element to the new + * one. See the {@link guide/directive#creating-custom-directives_creating-directives_template-expanding-directive + * Directives Guide} for an example. + * + * There are very few scenarios where element replacement is required for the application function, + * the main one being reusable custom components that are used within SVG contexts + * (because SVG doesn't work with custom elements in the DOM tree). * * #### `transclude` * compile the content of the element and make it available to the directive. @@ -5333,6 +5573,11 @@ function $TemplateCacheProvider() { * * `true` - transclude the content of the directive. * * `'element'` - transclude the whole element including any directives defined at lower priority. * + *
    + * **Note:** When testing an element transclude directive you must not place the directive at the root of the + * DOM fragment that is being compiled. See {@link guide/unit-testing#testing-transclusion-directives + * Testing Transclusion Directives}. + *
    * * #### `compile` * @@ -5410,10 +5655,9 @@ function $TemplateCacheProvider() { * the directives to use the controllers as a communication channel. * * * `transcludeFn` - A transclude linking function pre-bound to the correct transclusion scope. - * The scope can be overridden by an optional first argument. This is the same as the `$transclude` - * parameter of directive controllers. - * `function([scope], cloneLinkingFn)`. - * + * This is the same as the `$transclude` + * parameter of directive controllers, see there for details. + * `function([scope], cloneLinkingFn, futureParentElement)`. * * #### Pre-linking function * @@ -5422,7 +5666,14 @@ function $TemplateCacheProvider() { * * #### Post-linking function * - * Executed after the child elements are linked. It is safe to do DOM transformation in the post-linking function. + * Executed after the child elements are linked. + * + * Note that child elements that contain `templateUrl` directives will not have been compiled + * and linked since they are waiting for their template to load asynchronously and their own + * compilation and linking has been suspended until that occurs. + * + * It is safe to do DOM transformation in the post-linking function on elements that are not waiting + * for their async templates to be resolved. * * * ### Attributes @@ -5578,7 +5829,6 @@ var $compileMinErr = minErr('$compile'); /** * @ngdoc provider * @name $compileProvider - * @kind function * * @description */ @@ -5587,7 +5837,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var hasDirectives = {}, Suffix = 'Directive', COMMENT_DIRECTIVE_REGEXP = /^\s*directive\:\s*([\d\w_\-]+)\s+(.*)$/, - CLASS_DIRECTIVE_REGEXP = /(([\d\w_\-]+)(?:\:([^;]+))?;?)/; + CLASS_DIRECTIVE_REGEXP = /(([\d\w_\-]+)(?:\:([^;]+))?;?)/, + ALL_OR_NOTHING_ATTRS = makeMap('ngSrc,ngSrcset,src,srcset'); // Ref: http://developers.whatwg.org/webappapis.html#event-handler-idl-attributes // The assumption is that future DOM event attribute names will begin with @@ -5630,7 +5881,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { directive.index = index; directive.name = directive.name || name; directive.require = directive.require || (directive.controller && directive.name); - directive.restrict = directive.restrict || 'A'; + directive.restrict = directive.restrict || 'EA'; directives.push(directive); } catch (e) { $exceptionHandler(e); @@ -5706,15 +5957,57 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }; + /** + * @ngdoc method + * @name $compileProvider#debugInfoEnabled + * + * @param {boolean=} enabled update the debugInfoEnabled state if provided, otherwise just return the + * current debugInfoEnabled state + * @returns {*} current value if used as getter or itself (chaining) if used as setter + * + * @kind function + * + * @description + * Call this method to enable/disable various debug runtime information in the compiler such as adding + * binding information and a reference to the current scope on to DOM elements. + * If enabled, the compiler will add the following to DOM elements that have been bound to the scope + * * `ng-binding` CSS class + * * `$binding` data property containing an array of the binding expressions + * + * You may want to use this in production for a significant performance boost. See + * {@link guide/production#disabling-debug-data Disabling Debug Data} for more. + * + * The default value is true. + */ + var debugInfoEnabled = true; + this.debugInfoEnabled = function(enabled) { + if(isDefined(enabled)) { + debugInfoEnabled = enabled; + return this; + } + return debugInfoEnabled; + }; + this.$get = [ - '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', + '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$document', '$sce', '$animate', '$$sanitizeUri', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, + function($injector, $interpolate, $exceptionHandler, $templateRequest, $parse, $controller, $rootScope, $document, $sce, $animate, $$sanitizeUri) { - var Attributes = function(element, attr) { + var Attributes = function(element, attributesToCopy) { + if (attributesToCopy) { + var keys = Object.keys(attributesToCopy); + var i, l, key; + + for (i = 0, l = keys.length; i < l; i++) { + key = keys[i]; + this[key] = attributesToCopy[key]; + } + } else { + this.$attr = {}; + } + this.$$element = element; - this.$attr = attr || {}; }; Attributes.prototype = { @@ -5769,14 +6062,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { */ $updateClass : function(newClasses, oldClasses) { var toAdd = tokenDifference(newClasses, oldClasses); - var toRemove = tokenDifference(oldClasses, newClasses); - - if(toAdd.length === 0) { - $animate.removeClass(this.$$element, toRemove); - } else if(toRemove.length === 0) { + if (toAdd && toAdd.length) { $animate.addClass(this.$$element, toAdd); - } else { - $animate.setClass(this.$$element, toAdd, toRemove); + } + + var toRemove = tokenDifference(oldClasses, newClasses); + if (toRemove && toRemove.length) { + $animate.removeClass(this.$$element, toRemove); } }, @@ -5794,13 +6086,19 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { //is set through this function since it may cause $updateClass to //become unstable. - var booleanKey = getBooleanAttrName(this.$$element[0], key), + var node = this.$$element[0], + booleanKey = getBooleanAttrName(node, key), + aliasedKey = getAliasedAttrName(node, key), + observer = key, normalizedVal, nodeName; if (booleanKey) { this.$$element.prop(key, value); attrName = booleanKey; + } else if(aliasedKey) { + this[aliasedKey] = value; + observer = aliasedKey; } this[key] = value; @@ -5818,8 +6116,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { nodeName = nodeName_(this.$$element); // sanitize a[href] and img[src] values - if ((nodeName === 'A' && key === 'href') || - (nodeName === 'IMG' && key === 'src')) { + if ((nodeName === 'a' && key === 'href') || + (nodeName === 'img' && key === 'src')) { this[key] = value = $$sanitizeUri(value, key === 'src'); } @@ -5833,7 +6131,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // fire observers var $$observers = this.$$observers; - $$observers && forEach($$observers[key], function(fn) { + $$observers && forEach($$observers[observer], function(fn) { try { fn(value); } catch (e) { @@ -5859,7 +6157,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { * @param {function(interpolatedValue)} fn Function that will be called whenever the interpolated value of the attribute changes. * See the {@link guide/directive#Attributes Directives} guide for more info. - * @returns {function()} the `fn` parameter. + * @returns {function()} Returns a deregistration function for this observer. */ $observe: function(key, fn) { var attrs = this, @@ -5873,10 +6171,24 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { fn(attrs[key]); } }); - return fn; + + return function() { + arrayRemove(listeners, fn); + }; } }; + + function safeAddClass($element, className) { + try { + $element.addClass(className); + } catch(e) { + // ignore, since it means that we are trying to set class on + // SVG element, where class name is read-only. + } + } + + var startSymbol = $interpolate.startSymbol(), endSymbol = $interpolate.endSymbol(), denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') @@ -5886,6 +6198,30 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { }, NG_ATTR_BINDING = /^ngAttr[A-Z]/; + compile.$$addBindingInfo = debugInfoEnabled ? function $$addBindingInfo($element, binding) { + var bindings = $element.data('$binding') || []; + + if (isArray(binding)) { + bindings = bindings.concat(binding); + } else { + bindings.push(binding); + } + + $element.data('$binding', bindings); + } : noop; + + compile.$$addBindingClass = debugInfoEnabled ? function $$addBindingClass($element) { + safeAddClass($element, 'ng-binding'); + } : noop; + + compile.$$addScopeInfo = debugInfoEnabled ? function $$addScopeInfo($element, scope, isolated, noTemplate) { + var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; + $element.data(dataName, scope); + } : noop; + + compile.$$addScopeClass = debugInfoEnabled ? function $$addScopeClass($element, isolated) { + safeAddClass($element, isolated ? 'ng-isolate-scope' : 'ng-scope'); + } : noop; return compile; @@ -5902,46 +6238,57 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // not be able to attach scope data to them, so we will wrap them in forEach($compileNodes, function(node, index){ if (node.nodeType == 3 /* text node */ && node.nodeValue.match(/\S+/) /* non-empty */ ) { - $compileNodes[index] = node = jqLite(node).wrap('').parent()[0]; + $compileNodes[index] = jqLite(node).wrap('').parent()[0]; } }); var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); - safeAddClass($compileNodes, 'ng-scope'); - return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn){ + compile.$$addScopeClass($compileNodes); + var namespace = null; + var namespaceAdaptedCompileNodes = $compileNodes; + var lastCompileNode; + return function publicLinkFn(scope, cloneConnectFn, transcludeControllers, parentBoundTranscludeFn, futureParentElement){ assertArg(scope, 'scope'); + if (!namespace) { + namespace = detectNamespaceForChildElements(futureParentElement); + } + if (namespace !== 'html' && $compileNodes[0] !== lastCompileNode) { + namespaceAdaptedCompileNodes = jqLite( + wrapTemplate(namespace, jqLite('
    ').append($compileNodes).html()) + ); + } + // When using a directive with replace:true and templateUrl the $compileNodes + // might change, so we need to recreate the namespace adapted compileNodes. + lastCompileNode = $compileNodes[0]; + // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart // and sometimes changes the structure of the DOM. var $linkNode = cloneConnectFn - ? JQLitePrototype.clone.call($compileNodes) // IMPORTANT!!! - : $compileNodes; + ? JQLitePrototype.clone.call(namespaceAdaptedCompileNodes) // IMPORTANT!!! + : namespaceAdaptedCompileNodes; - forEach(transcludeControllers, function(instance, name) { - $linkNode.data('$' + name + 'Controller', instance); - }); - - // Attach scope only to non-text nodes. - for(var i = 0, ii = $linkNode.length; i addDirective(directives, - directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority, ignoreDirective); + directiveNormalize(nodeName_(node)), 'E', maxPriority, ignoreDirective); // iterate over the attributes for (var attr, name, nName, ngAttrName, value, isNgAttr, nAttrs = node.attributes, @@ -6106,10 +6468,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } var directiveNName = ngAttrName.replace(/(Start|End)$/, ''); - if (ngAttrName === directiveNName + 'Start') { - attrStartName = name; - attrEndName = name.substr(0, name.length - 5) + 'end'; - name = name.substr(0, name.length - 6); + if (directiveIsMultiElement(directiveNName)) { + if (ngAttrName === directiveNName + 'Start') { + attrStartName = name; + attrEndName = name.substr(0, name.length - 5) + 'end'; + name = name.substr(0, name.length - 6); + } } nName = directiveNormalize(name.toLowerCase()); @@ -6120,7 +6484,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { attrs[nName] = true; // presence means true } } - addAttrInterpolateDirective(node, directives, value, nName); + addAttrInterpolateDirective(node, directives, value, nName, isNgAttr); addDirective(directives, nName, 'A', maxPriority, ignoreDirective, attrStartName, attrEndName); } @@ -6241,6 +6605,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var terminalPriority = -Number.MAX_VALUE, newScopeDirective, controllerDirectives = previousCompileContext.controllerDirectives, + controllers, newIsolateScopeDirective = previousCompileContext.newIsolateScopeDirective, templateDirective = previousCompileContext.templateDirective, nonTlbTranscludeDirective = previousCompileContext.nonTlbTranscludeDirective, @@ -6273,17 +6638,25 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } if (directiveValue = directive.scope) { - newScopeDirective = newScopeDirective || directive; // skip the check for directives with async templates, we'll check the derived sync // directive when the template arrives if (!directive.templateUrl) { - assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, - $compileNode); if (isObject(directiveValue)) { + // This directive is trying to add an isolated scope. + // Check that there is no scope of any kind already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective || newScopeDirective, + directive, $compileNode); newIsolateScopeDirective = directive; + } else { + // This directive is trying to add a child scope. + // Check that there is no isolated scope already + assertNoDuplicate('new/isolated scope', newIsolateScopeDirective, directive, + $compileNode); } } + + newScopeDirective = newScopeDirective || directive; } directiveName = directive.name; @@ -6310,12 +6683,12 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (directiveValue == 'element') { hasElementTranscludeDirective = true; terminalPriority = directive.priority; - $template = groupScan(compileNode, attrStart, attrEnd); + $template = $compileNode; $compileNode = templateAttrs.$$element = jqLite(document.createComment(' ' + directiveName + ': ' + templateAttrs[directiveName] + ' ')); compileNode = $compileNode[0]; - replaceWith(jqCollection, jqLite(sliceArgs($template)), compileNode); + replaceWith(jqCollection, sliceArgs($template), compileNode); childTranscludeFn = compile($template, transcludeFn, terminalPriority, replaceDirective && replaceDirective.name, { @@ -6351,7 +6724,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (jqLiteIsTextNode(directiveValue)) { $template = []; } else { - $template = jqLite(trim(directiveValue)); + $template = jqLite(wrapTemplate(directive.templateNamespace, trim(directiveValue))); } compileNode = $template[0]; @@ -6424,6 +6797,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope === true; nodeLinkFn.transcludeOnThisElement = hasTranscludeDirective; + nodeLinkFn.elementTranscludeOnThisElement = hasElementTranscludeDirective; nodeLinkFn.templateOnThisElement = hasTemplate; nodeLinkFn.transclude = childTranscludeFn; @@ -6469,7 +6843,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { value = null; if (elementControllers && retrievalMethod === 'data') { - value = elementControllers[require]; + if (value = elementControllers[require]) { + value = value.instance; + } } value = value || $element[retrievalMethod]('$' + require + 'Controller'); @@ -6490,32 +6866,68 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) { - var attrs, $element, i, ii, linkFn, controller, isolateScope, elementControllers = {}, transcludeFn; + var i, ii, linkFn, controller, isolateScope, elementControllers, transcludeFn, $element, + attrs; if (compileNode === linkNode) { attrs = templateAttrs; + $element = templateAttrs.$$element; } else { - attrs = shallowCopy(templateAttrs, new Attributes(jqLite(linkNode), templateAttrs.$attr)); + $element = jqLite(linkNode); + attrs = new Attributes($element, templateAttrs); + } + + if (newIsolateScopeDirective) { + isolateScope = scope.$new(true); + } + + transcludeFn = boundTranscludeFn && controllersBoundTransclude; + if (controllerDirectives) { + // TODO: merge `controllers` and `elementControllers` into single object. + controllers = {}; + elementControllers = {}; + forEach(controllerDirectives, function(directive) { + var locals = { + $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, + $element: $element, + $attrs: attrs, + $transclude: transcludeFn + }, controllerInstance; + + controller = directive.controller; + if (controller == '@') { + controller = attrs[directive.name]; + } + + controllerInstance = $controller(controller, locals, true, directive.controllerAs); + + // For directives with element transclusion the element is a comment, + // but jQuery .data doesn't support attaching data to comment nodes as it's hard to + // clean up (http://bugs.jquery.com/ticket/8335). + // Instead, we save the controllers for the element in a local hash and attach to .data + // later, once we have the actual element. + elementControllers[directive.name] = controllerInstance; + if (!hasElementTranscludeDirective) { + $element.data('$' + directive.name + 'Controller', controllerInstance.instance); + } + + controllers[directive.name] = controllerInstance; + }); } - $element = attrs.$$element; if (newIsolateScopeDirective) { var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; - var $linkNode = jqLite(linkNode); - isolateScope = scope.$new(true); + compile.$$addScopeInfo($element, isolateScope, true, !(templateDirective && (templateDirective === newIsolateScopeDirective || + templateDirective === newIsolateScopeDirective.$$originalDirective))); + compile.$$addScopeClass($element, true); - if (templateDirective && (templateDirective === newIsolateScopeDirective || - templateDirective === newIsolateScopeDirective.$$originalDirective)) { - $linkNode.data('$isolateScope', isolateScope) ; - } else { - $linkNode.data('$isolateScopeNoTemplate', isolateScope); + var isolateScopeController = controllers && controllers[newIsolateScopeDirective.name]; + var isolateBindingContext = isolateScope; + if (isolateScopeController && isolateScopeController.identifier && + newIsolateScopeDirective.bindToController === true) { + isolateBindingContext = isolateScopeController.instance; } - - - - safeAddClass($linkNode, 'ng-isolate-scope'); - forEach(newIsolateScopeDirective.scope, function(definition, scopeName) { var match = definition.match(LOCAL_REGEXP) || [], attrName = match[3] || scopeName, @@ -6536,7 +6948,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if( attrs[attrName] ) { // If the attribute has been provided then we trigger an interpolation to ensure // the value is there for use in the link fn - isolateScope[scopeName] = $interpolate(attrs[attrName])(scope); + isolateBindingContext[scopeName] = $interpolate(attrs[attrName])(scope); } break; @@ -6548,35 +6960,35 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (parentGet.literal) { compare = equals; } else { - compare = function(a,b) { return a === b; }; + compare = function(a,b) { return a === b || (a !== a && b !== b); }; } parentSet = parentGet.assign || function() { // reset the change, or we will throw this exception on every $digest - lastValue = isolateScope[scopeName] = parentGet(scope); + lastValue = isolateBindingContext[scopeName] = parentGet(scope); throw $compileMinErr('nonassign', "Expression '{0}' used with directive '{1}' is non-assignable!", attrs[attrName], newIsolateScopeDirective.name); }; - lastValue = isolateScope[scopeName] = parentGet(scope); - isolateScope.$watch(function parentValueWatch() { - var parentValue = parentGet(scope); - if (!compare(parentValue, isolateScope[scopeName])) { + lastValue = isolateBindingContext[scopeName] = parentGet(scope); + var unwatch = scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) { + if (!compare(parentValue, isolateBindingContext[scopeName])) { // we are out of sync and need to copy if (!compare(parentValue, lastValue)) { // parent changed and it has precedence - isolateScope[scopeName] = parentValue; + isolateBindingContext[scopeName] = parentValue; } else { // if the parent can be assigned then do so - parentSet(scope, parentValue = isolateScope[scopeName]); + parentSet(scope, parentValue = isolateBindingContext[scopeName]); } } return lastValue = parentValue; - }, null, parentGet.literal); + }), null, parentGet.literal); + isolateScope.$on('$destroy', unwatch); break; case '&': parentGet = $parse(attrs[attrName]); - isolateScope[scopeName] = function(locals) { + isolateBindingContext[scopeName] = function(locals) { return parentGet(scope, locals); }; break; @@ -6589,47 +7001,23 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }); } - transcludeFn = boundTranscludeFn && controllersBoundTransclude; - if (controllerDirectives) { - forEach(controllerDirectives, function(directive) { - var locals = { - $scope: directive === newIsolateScopeDirective || directive.$$isolateScope ? isolateScope : scope, - $element: $element, - $attrs: attrs, - $transclude: transcludeFn - }, controllerInstance; - - controller = directive.controller; - if (controller == '@') { - controller = attrs[directive.name]; - } - - controllerInstance = $controller(controller, locals); - // For directives with element transclusion the element is a comment, - // but jQuery .data doesn't support attaching data to comment nodes as it's hard to - // clean up (http://bugs.jquery.com/ticket/8335). - // Instead, we save the controllers for the element in a local hash and attach to .data - // later, once we have the actual element. - elementControllers[directive.name] = controllerInstance; - if (!hasElementTranscludeDirective) { - $element.data('$' + directive.name + 'Controller', controllerInstance); - } - - if (directive.controllerAs) { - locals.$scope[directive.controllerAs] = controllerInstance; - } + if (controllers) { + forEach(controllers, function(controller) { + controller(); }); + controllers = null; } // PRELINKING for(i = 0, ii = preLinkFns.length; i < ii; i++) { - try { - linkFn = preLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + linkFn = preLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } // RECURSION @@ -6643,21 +7031,24 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // POSTLINKING for(i = postLinkFns.length - 1; i >= 0; i--) { - try { - linkFn = postLinkFns[i]; - linkFn(linkFn.isolateScope ? isolateScope : scope, $element, attrs, - linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), transcludeFn); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } + linkFn = postLinkFns[i]; + invokeLinkFn(linkFn, + linkFn.isolateScope ? isolateScope : scope, + $element, + attrs, + linkFn.require && getControllers(linkFn.directiveName, linkFn.require, $element, elementControllers), + transcludeFn + ); } // This is the function that is injected as `$transclude`. - function controllersBoundTransclude(scope, cloneAttachFn) { + // Note: all arguments are optional! + function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement) { var transcludeControllers; - // no scope passed - if (arguments.length < 2) { + // No scope passed in: + if (!isScope(scope)) { + futureParentElement = cloneAttachFn; cloneAttachFn = scope; scope = undefined; } @@ -6665,8 +7056,10 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (hasElementTranscludeDirective) { transcludeControllers = elementControllers; } - - return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers); + if (!futureParentElement) { + futureParentElement = hasElementTranscludeDirective ? $element.parent() : $element; + } + return boundTranscludeFn(scope, cloneAttachFn, transcludeControllers, futureParentElement); } } } @@ -6716,6 +7109,27 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } + /** + * looks up the directive and returns true if it is a multi-element directive, + * and therefore requires DOM nodes between -start and -end markers to be grouped + * together. + * + * @param {string} name name of the directive to look up. + * @returns true if directive was registered as multi-element. + */ + function directiveIsMultiElement(name) { + if (hasDirectives.hasOwnProperty(name)) { + for(var directive, directives = $injector.get(name + Suffix), + i = 0, ii = directives.length; i'+template+''; + return wrapper.childNodes[0].childNodes; + default: + return template; + } + } function getTrustedContext(node, attrNormalizedName) { @@ -6926,22 +7351,22 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var tag = nodeName_(node); // maction[xlink:href] can source SVG. It's not limited to . if (attrNormalizedName == "xlinkHref" || - (tag == "FORM" && attrNormalizedName == "action") || - (tag != "IMG" && (attrNormalizedName == "src" || + (tag == "form" && attrNormalizedName == "action") || + (tag != "img" && (attrNormalizedName == "src" || attrNormalizedName == "ngSrc"))) { return $sce.RESOURCE_URL; } } - function addAttrInterpolateDirective(node, directives, value, name) { + function addAttrInterpolateDirective(node, directives, value, name, allOrNothing) { var interpolateFn = $interpolate(value, true); // no interpolation found -> ignore if (!interpolateFn) return; - if (name === "multiple" && nodeName_(node) === "SELECT") { + if (name === "multiple" && nodeName_(node) === "select") { throw $compileMinErr("selmulti", "Binding to the 'multiple' attribute is not supported. Element: {0}", startingTag(node)); @@ -6962,15 +7387,18 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // we need to interpolate again, in case the attribute value has been updated // (e.g. by another directive's compile function) - interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name)); + interpolateFn = $interpolate(attr[name], true, getTrustedContext(node, name), + ALL_OR_NOTHING_ATTRS[name] || allOrNothing); // if attribute was updated so that there is no interpolation going on we don't want to // register any observers if (!interpolateFn) return; - // TODO(i): this should likely be attr.$set(name, iterpolateFn(scope) so that we reset the - // actual attr value + // initialize attr object so that it's ready in case we need the value for isolate + // scope initialization, otherwise the value would not be available from isolate + // directive's linking fn during linking phase attr[name] = interpolateFn(scope); + ($$observers[name] || ($$observers[name] = [])).$$inter = true; (attr.$$observers && attr.$$observers[name].$$scope || scope). $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { @@ -7023,6 +7451,13 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } $rootElement.length -= removeCount - 1; + + // If the replaced element is also the jQuery .context then replace it + // .context is a deprecated jQuery api, so we should set it only when jQuery set it + // http://api.jquery.com/context/ + if ($rootElement.context === firstElementToRemove) { + $rootElement.context = newNode; + } break; } } @@ -7031,9 +7466,33 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { if (parent) { parent.replaceChild(newNode, firstElementToRemove); } + + // TODO(perf): what's this document fragment for? is it needed? can we at least reuse it? var fragment = document.createDocumentFragment(); fragment.appendChild(firstElementToRemove); - newNode[jqLite.expando] = firstElementToRemove[jqLite.expando]; + + // Copy over user data (that includes Angular's $scope etc.). Don't copy private + // data here because there's no public interface in jQuery to do that and copying over + // event listeners (which is the main use of private data) wouldn't work anyway. + jqLite(newNode).data(jqLite(firstElementToRemove).data()); + + // Remove data of the replaced element. We cannot just call .remove() + // on the element it since that would deallocate scope that is needed + // for the new node. Instead, remove the data "manually". + if (!jQuery) { + delete jqLite.cache[firstElementToRemove[jqLite.expando]]; + } else { + // jQuery 2.x doesn't expose the data storage. Use jQuery.cleanData to clean up after + // the replaced element. The cleanData version monkey-patched by Angular would cause + // the scope to be trashed and we do need the very same scope to work with the new + // element. However, we cannot just cache the non-patched version and use it here as + // that would break if another library patches the method after Angular does (one + // example is jQuery UI). Instead, set a flag indicating scope destroying should be + // skipped this one time. + skipDestroyOnNextJQueryCleanData = true; + jQuery.cleanData([firstElementToRemove]); + } + for (var k = 1, kk = elementsToRemove.length; k < kk; k++) { var element = elementsToRemove[k]; jqLite(element).remove(); // must do this way to clean up expando @@ -7049,6 +7508,15 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { function cloneAndAnnotateFn(fn, annotation) { return extend(function() { return fn.apply(null, arguments); }, fn, annotation); } + + + function invokeLinkFn(linkFn, scope, $element, attrs, controllers, transcludeFn) { + try { + linkFn(scope, $element, attrs, controllers, transcludeFn); + } catch(e) { + $exceptionHandler(e, startingTag($element)); + } + } }]; } @@ -7085,8 +7553,10 @@ function directiveNormalize(name) { /** * @ngdoc property * @name $compile.directive.Attributes#$attr - * @returns {object} A map of DOM element attribute names to the normalized name. This is - * needed to do reverse lookup from normalized name back to actual name. + * + * @description + * A map of DOM element attribute names to the normalized name. This is + * needed to do reverse lookup from normalized name back to actual name. */ @@ -7154,6 +7624,7 @@ function tokenDifference(str1, str2) { */ function $ControllerProvider() { var controllers = {}, + globals = false, CNTRL_REG = /^(\S+)(\s+as\s+(\w+))?$/; @@ -7174,6 +7645,15 @@ function $ControllerProvider() { } }; + /** + * @ngdoc method + * @name $controllerProvider#allowGlobals + * @description If called, allows `$controller` to find controller constructors on `window` + */ + this.allowGlobals = function() { + globals = true; + }; + this.$get = ['$injector', '$window', function($injector, $window) { @@ -7188,7 +7668,8 @@ function $ControllerProvider() { * * * check if a controller with given name is registered via `$controllerProvider` * * check if evaluating the string on the current scope returns a constructor - * * check `window[constructor]` on the global `window` object + * * if $controllerProvider#allowGlobals, check `window[constructor]` on the global + * `window` object (not recommended) * * @param {Object} locals Injection locals for Controller. * @return {Object} Instance of given controller. @@ -7199,34 +7680,78 @@ function $ControllerProvider() { * It's just a simple call to {@link auto.$injector $injector}, but extracted into * a service, so that one can override this service with [BC version](https://gist.github.com/1649788). */ - return function(expression, locals) { + return function(expression, locals, later, ident) { + // PRIVATE API: + // param `later` --- indicates that the controller's constructor is invoked at a later time. + // If true, $controller will allocate the object with the correct + // prototype chain, but will not invoke the controller until a returned + // callback is invoked. + // param `ident` --- An optional label which overrides the label parsed from the controller + // expression, if any. var instance, match, constructor, identifier; + later = later === true; + if (ident && isString(ident)) { + identifier = ident; + } if(isString(expression)) { match = expression.match(CNTRL_REG), constructor = match[1], - identifier = match[3]; + identifier = identifier || match[3]; expression = controllers.hasOwnProperty(constructor) ? controllers[constructor] - : getter(locals.$scope, constructor, true) || getter($window, constructor, true); + : getter(locals.$scope, constructor, true) || + (globals ? getter($window, constructor, true) : undefined); assertArgFn(expression, constructor, true); } - instance = $injector.instantiate(expression, locals); + if (later) { + // Instantiate controller later: + // This machinery is used to create an instance of the object before calling the + // controller's constructor itself. + // + // This allows properties to be added to the controller before the constructor is + // invoked. Primarily, this is used for isolate scope bindings in $compile. + // + // This feature is not intended for use by applications, and is thus not documented + // publicly. + var Constructor = function() {}; + Constructor.prototype = (isArray(expression) ? + expression[expression.length - 1] : expression).prototype; + instance = new Constructor(); - if (identifier) { - if (!(locals && typeof locals.$scope === 'object')) { - throw minErr('$controller')('noscp', - "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", - constructor || expression.name, identifier); + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } - locals.$scope[identifier] = instance; + return extend(function() { + $injector.invoke(expression, instance, locals, constructor); + return instance; + }, { + instance: instance, + identifier: identifier + }); + } + + instance = $injector.instantiate(expression, locals, constructor); + + if (identifier) { + addIdentifier(locals, identifier, instance, constructor || expression.name); } return instance; }; + + function addIdentifier(locals, identifier, instance, name) { + if (!(locals && isObject(locals.$scope))) { + throw minErr('$controller')('noscp', + "Cannot export controller '{0}' as '{1}'! No $scope object provided via `locals`.", + name, identifier); + } + + locals.$scope[identifier] = instance; + } }]; } @@ -7318,11 +7843,7 @@ function parseHeaders(headers) { val = trim(line.substr(i + 1)); if (key) { - if (parsed[key]) { - parsed[key] += ', ' + val; - } else { - parsed[key] = val; - } + parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; } }); @@ -7448,18 +7969,40 @@ function $HttpProvider() { xsrfHeaderName: 'X-XSRF-TOKEN' }; + var useApplyAsync = false; + /** + * @ngdoc method + * @name $httpProvider#useApplyAsync + * @description + * + * Configure $http service to combine processing of multiple http responses received at around + * the same time via {@link ng.$rootScope#applyAsync $rootScope.$applyAsync}. This can result in + * significant performance improvement for bigger applications that make many HTTP requests + * concurrently (common during application bootstrap). + * + * Defaults to false. If no value is specifed, returns the current configured value. + * + * @param {boolean=} value If true, when requests are loaded, they will schedule a deferred + * "apply" on the next tick, giving time for subsequent requests in a roughly ~10ms window + * to load and share the same digest cycle. + * + * @returns {boolean|Object} If a value is specified, returns the $httpProvider for chaining. + * otherwise, returns the current configured value. + **/ + this.useApplyAsync = function(value) { + if (isDefined(value)) { + useApplyAsync = !!value; + return this; + } + return useApplyAsync; + }; + /** * Are ordered by request, i.e. they are applied in the same order as the * array, on request, but reverse order, on response. */ var interceptorFactories = this.interceptors = []; - /** - * For historical reasons, response interceptors are ordered by the order in which - * they are applied to the response. (This is the opposite of interceptorFactories) - */ - var responseInterceptorFactories = this.responseInterceptors = []; - this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { @@ -7477,27 +8020,6 @@ function $HttpProvider() { ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory)); }); - forEach(responseInterceptorFactories, function(interceptorFactory, index) { - var responseFn = isString(interceptorFactory) - ? $injector.get(interceptorFactory) - : $injector.invoke(interceptorFactory); - - /** - * Response interceptors go before "around" interceptors (no real reason, just - * had to pick one.) But they are already reversed, so we can't use unshift, hence - * the splice. - */ - reversedInterceptors.splice(index, 0, { - response: function(response) { - return responseFn($q.when(response)); - }, - responseError: function(response) { - return responseFn($q.reject(response)); - } - }); - }); - - /** * @ngdoc service * @kind function @@ -7524,7 +8046,7 @@ function $HttpProvider() { * it is important to familiarize yourself with these APIs and the guarantees they provide. * * - * # General usage + * ## General usage * The `$http` service is a function which takes a single argument — a configuration object — * that is used to generate an HTTP request and returns a {@link ng.$q promise} * with two $http specific methods: `success` and `error`. @@ -7551,7 +8073,7 @@ function $HttpProvider() { * XMLHttpRequest will transparently follow it, meaning that the error callback will not be * called for such responses. * - * # Writing Unit Tests that use $http + * ## Writing Unit Tests that use $http * When unit testing (using {@link ngMock ngMock}), it is necessary to call * {@link ngMock.$httpBackend#flush $httpBackend.flush()} to flush each pending * request using trained responses. @@ -7562,7 +8084,7 @@ function $HttpProvider() { * $httpBackend.flush(); * ``` * - * # Shortcut methods + * ## Shortcut methods * * Shortcut methods are also available. All shortcut methods require passing in the URL, and * request data must be passed in for POST/PUT requests. @@ -7580,9 +8102,10 @@ function $HttpProvider() { * - {@link ng.$http#put $http.put} * - {@link ng.$http#delete $http.delete} * - {@link ng.$http#jsonp $http.jsonp} + * - {@link ng.$http#patch $http.patch} * * - * # Setting HTTP Headers + * ## Setting HTTP Headers * * The $http service will automatically add certain HTTP headers to all requests. These defaults * can be fully configured by accessing the `$httpProvider.defaults.headers` configuration @@ -7613,36 +8136,69 @@ function $HttpProvider() { * calling `$http(config)`, which overrides the defaults without changing them globally. * * - * # Transforming Requests and Responses + * ## Transforming Requests and Responses * - * Both requests and responses can be transformed using transform functions. By default, Angular - * applies these transformations: + * Both requests and responses can be transformed using transformation functions: `transformRequest` + * and `transformResponse`. These properties can be a single function that returns + * the transformed value (`{function(data, headersGetter)`) or an array of such transformation functions, + * which allows you to `push` or `unshift` a new transformation function into the transformation chain. * - * Request transformations: + * ### Default Transformations + * + * The `$httpProvider` provider and `$http` service expose `defaults.transformRequest` and + * `defaults.transformResponse` properties. If a request does not provide its own transformations + * then these will be applied. + * + * You can augment or replace the default transformations by modifying these properties by adding to or + * replacing the array. + * + * Angular provides the following default transformations: + * + * Request transformations (`$httpProvider.defaults.transformRequest` and `$http.defaults.transformRequest`): * * - If the `data` property of the request configuration object contains an object, serialize it * into JSON format. * - * Response transformations: + * Response transformations (`$httpProvider.defaults.transformResponse` and `$http.defaults.transformResponse`): * * - If XSRF prefix is detected, strip it (see Security Considerations section below). * - If JSON response is detected, deserialize it using a JSON parser. * - * To globally augment or override the default transforms, modify the - * `$httpProvider.defaults.transformRequest` and `$httpProvider.defaults.transformResponse` - * properties. These properties are by default an array of transform functions, which allows you - * to `push` or `unshift` a new transformation function into the transformation chain. You can - * also decide to completely override any default transformations by assigning your - * transformation functions to these properties directly without the array wrapper. These defaults - * are again available on the $http factory at run-time, which may be useful if you have run-time - * services you wish to be involved in your transformations. * - * Similarly, to locally override the request/response transforms, augment the - * `transformRequest` and/or `transformResponse` properties of the configuration object passed + * ### Overriding the Default Transformations Per Request + * + * If you wish override the request/response transformations only for a single request then provide + * `transformRequest` and/or `transformResponse` properties on the configuration object passed * into `$http`. * + * Note that if you provide these properties on the config object the default transformations will be + * overwritten. If you wish to augment the default transformations then you must include them in your + * local transformation array. * - * # Caching + * The following code demonstrates adding a new response transformation to be run after the default response + * transformations have been run. + * + * ```js + * function appendTransform(defaults, transform) { + * + * // We can't guarantee that the default transformation is an array + * defaults = angular.isArray(defaults) ? defaults : [defaults]; + * + * // Append the new transformation to the defaults + * return defaults.concat(transform); + * } + * + * $http({ + * url: '...', + * method: 'GET', + * transformResponse: appendTransform($http.defaults.transformResponse, function(value) { + * return doTransform(value); + * }) + * }); + * ``` + * + * + * ## Caching * * To enable caching, set the request configuration `cache` property to `true` (to use default * cache) or to a custom cache object (built with {@link ng.$cacheFactory `$cacheFactory`}). @@ -7665,7 +8221,7 @@ function $HttpProvider() { * If you set the default cache to `false` then only requests that specify their own custom * cache object will be cached. * - * # Interceptors + * ## Interceptors * * Before you start creating interceptors, be sure to understand the * {@link ng.$q $q and deferred/promise APIs}. @@ -7750,52 +8306,7 @@ function $HttpProvider() { * }); * ``` * - * # Response interceptors (DEPRECATED) - * - * Before you start creating interceptors, be sure to understand the - * {@link ng.$q $q and deferred/promise APIs}. - * - * For purposes of global error handling, authentication or any kind of synchronous or - * asynchronous preprocessing of received responses, it is desirable to be able to intercept - * responses for http requests before they are handed over to the application code that - * initiated these requests. The response interceptors leverage the {@link ng.$q - * promise apis} to fulfil this need for both synchronous and asynchronous preprocessing. - * - * The interceptors are service factories that are registered with the $httpProvider by - * adding them to the `$httpProvider.responseInterceptors` array. The factory is called and - * injected with dependencies (if specified) and returns the interceptor — a function that - * takes a {@link ng.$q promise} and returns the original or a new promise. - * - * ```js - * // register the interceptor as a service - * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return function(promise) { - * return promise.then(function(response) { - * // do something on success - * return response; - * }, function(response) { - * // do something on error - * if (canRecover(response)) { - * return responseOrNewPromise - * } - * return $q.reject(response); - * }); - * } - * }); - * - * $httpProvider.responseInterceptors.push('myHttpInterceptor'); - * - * - * // register the interceptor via an anonymous factory - * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { - * return function(promise) { - * // same as above - * } - * }); - * ``` - * - * - * # Security Considerations + * ## Security Considerations * * When designing web applications, consider security threats from: * @@ -7806,7 +8317,7 @@ function $HttpProvider() { * pre-configured with strategies that address these issues, but for this to work backend server * cooperation is required. * - * ## JSON Vulnerability Protection + * ### JSON Vulnerability Protection * * A [JSON vulnerability](http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx) * allows third party website to turn your JSON resource URL into @@ -7828,7 +8339,7 @@ function $HttpProvider() { * Angular will strip the prefix, before processing the JSON. * * - * ## Cross Site Request Forgery (XSRF) Protection + * ### Cross Site Request Forgery (XSRF) Protection * * [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) is a technique by which * an unauthorized site can gain your user's private data. Angular provides a mechanism @@ -7844,7 +8355,7 @@ function $HttpProvider() { * that only JavaScript running on your domain could have sent the request. The token must be * unique for each user and must be verifiable by the server (to prevent the JavaScript from * making up its own tokens). We recommend that the token is a digest of your site's - * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) + * authentication cookie with a [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) * for added security. * * The name of the headers can be specified using the xsrfHeaderName and xsrfCookieName @@ -7870,10 +8381,12 @@ function $HttpProvider() { * `{function(data, headersGetter)|Array.}` – * transform function or an array of such functions. The transform function takes the http * request body and headers and returns its transformed (typically serialized) version. + * See {@link #overriding-the-default-transformations-per-request Overriding the Default Transformations} * - **transformResponse** – * `{function(data, headersGetter)|Array.}` – * transform function or an array of such functions. The transform function takes the http * response body and headers and returns its transformed (typically deserialized) version. + * See {@link #overriding-the-default-transformations-per-request Overriding the Default Transformations} * - **cache** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the * GET request, otherwise if a cache instance built with * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for @@ -7881,7 +8394,7 @@ function $HttpProvider() { * - **timeout** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} * that should abort the request when resolved. * - **withCredentials** - `{boolean}` - whether to set the `withCredentials` flag on the - * XHR object. See [requests with credentials]https://developer.mozilla.org/en/http_access_control#section_5 + * XHR object. See [requests with credentials](https://developer.mozilla.org/docs/Web/HTTP/Access_control_CORS#Requests_with_credentials) * for more information. * - **responseType** - `{string}` - see * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). @@ -8159,7 +8672,7 @@ function $HttpProvider() { * Shortcut method to perform `JSONP` request. * * @param {string} url Relative or absolute URL specifying the destination of the request. - * Should contain `JSON_CALLBACK` string. + * The name of the callback should be the string `JSON_CALLBACK`. * @param {Object=} config Optional configuration object * @returns {HttpPromise} Future object */ @@ -8190,7 +8703,20 @@ function $HttpProvider() { * @param {Object=} config Optional configuration object * @returns {HttpPromise} Future object */ - createShortMethodsWithData('post', 'put'); + + /** + * @ngdoc method + * @name $http#patch + * + * @description + * Shortcut method to perform `PATCH` request. + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {HttpPromise} Future object + */ + createShortMethodsWithData('post', 'put', 'patch'); /** * @ngdoc property @@ -8250,7 +8776,8 @@ function $HttpProvider() { promise.then(removePendingReq, removePendingReq); - if ((config.cache || defaults.cache) && config.cache !== false && config.method == 'GET') { + if ((config.cache || defaults.cache) && config.cache !== false && + (config.method === 'GET' || config.method === 'JSONP')) { cache = isObject(config.cache) ? config.cache : isObject(defaults.cache) ? defaults.cache : defaultCache; @@ -8259,7 +8786,7 @@ function $HttpProvider() { if (cache) { cachedResp = cache.get(url); if (isDefined(cachedResp)) { - if (cachedResp.then) { + if (isPromiseLike(cachedResp)) { // cached request has already been sent, but there is no response yet cachedResp.then(removePendingReq, removePendingReq); return cachedResp; @@ -8311,8 +8838,16 @@ function $HttpProvider() { } } - resolvePromise(response, status, headersString, statusText); - if (!$rootScope.$$phase) $rootScope.$apply(); + function resolveHttpPromise() { + resolvePromise(response, status, headersString, statusText); + } + + if (useApplyAsync) { + $rootScope.$applyAsync(resolveHttpPromise); + } else { + resolveHttpPromise(); + if (!$rootScope.$$phase) $rootScope.$apply(); + } } @@ -8334,34 +8869,36 @@ function $HttpProvider() { function removePendingReq() { - var idx = indexOf($http.pendingRequests, config); + var idx = $http.pendingRequests.indexOf(config); if (idx !== -1) $http.pendingRequests.splice(idx, 1); } } function buildUrl(url, params) { - if (!params) return url; - var parts = []; - forEachSorted(params, function(value, key) { - if (value === null || isUndefined(value)) return; - if (!isArray(value)) value = [value]; + if (!params) return url; + var parts = []; + forEachSorted(params, function(value, key) { + if (value === null || isUndefined(value)) return; + if (!isArray(value)) value = [value]; - forEach(value, function(v) { - if (isObject(v)) { - v = toJson(v); - } - parts.push(encodeUriQuery(key) + '=' + - encodeUriQuery(v)); - }); - }); - if(parts.length > 0) { - url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + forEach(value, function(v) { + if (isObject(v)) { + if (isDate(v)){ + v = v.toISOString(); + } else { + v = toJson(v); + } } - return url; - } - - + parts.push(encodeUriQuery(key) + '=' + + encodeUriQuery(v)); + }); + }); + if(parts.length > 0) { + url += ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); + } + return url; + } }]; } @@ -8497,7 +9034,7 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc if (timeout > 0) { var timeoutId = $browserDefer(timeoutRequest, timeout); - } else if (timeout && timeout.then) { + } else if (isPromiseLike(timeout)) { timeout.then(timeoutRequest); } @@ -8561,18 +9098,6 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc addEventListenerFn(script, "load", callback); addEventListenerFn(script, "error", callback); - - if (msie <= 8) { - script.onreadystatechange = function() { - if (isString(script.readyState) && /loaded|complete/.test(script.readyState)) { - script.onreadystatechange = null; - callback({ - type: 'load' - }); - } - }; - } - rawDocument.body.appendChild(script); return callback; } @@ -8583,7 +9108,6 @@ var $interpolateMinErr = minErr('$interpolate'); /** * @ngdoc provider * @name $interpolateProvider - * @kind function * * @description * @@ -8659,7 +9183,13 @@ function $InterpolateProvider() { this.$get = ['$parse', '$exceptionHandler', '$sce', function($parse, $exceptionHandler, $sce) { var startSymbolLength = startSymbol.length, - endSymbolLength = endSymbol.length; + endSymbolLength = endSymbol.length, + escapedStartRegexp = new RegExp(startSymbol.replace(/./g, escape), 'g'), + escapedEndRegexp = new RegExp(endSymbol.replace(/./g, escape), 'g'); + + function escape(ch) { + return '\\\\\\' + ch; + } /** * @ngdoc service @@ -8683,6 +9213,62 @@ function $InterpolateProvider() { * expect(exp({name:'Angular'}).toEqual('Hello ANGULAR!'); * ``` * + * `$interpolate` takes an optional fourth argument, `allOrNothing`. If `allOrNothing` is + * `true`, the interpolation function will return `undefined` unless all embedded expressions + * evaluate to a value other than `undefined`. + * + * ```js + * var $interpolate = ...; // injected + * var context = {greeting: 'Hello', name: undefined }; + * + * // default "forgiving" mode + * var exp = $interpolate('{{greeting}} {{name}}!'); + * expect(exp(context)).toEqual('Hello !'); + * + * // "allOrNothing" mode + * exp = $interpolate('{{greeting}} {{name}}!', false, null, true); + * expect(exp(context)).toBeUndefined(); + * context.name = 'Angular'; + * expect(exp(context)).toEqual('Hello Angular!'); + * ``` + * + * `allOrNothing` is useful for interpolating URLs. `ngSrc` and `ngSrcset` use this behavior. + * + * ####Escaped Interpolation + * $interpolate provides a mechanism for escaping interpolation markers. Start and end markers + * can be escaped by preceding each of their characters with a REVERSE SOLIDUS U+005C (backslash). + * It will be rendered as a regular start/end marker, and will not be interpreted as an expression + * or binding. + * + * This enables web-servers to prevent script injection attacks and defacing attacks, to some + * degree, while also enabling code examples to work without relying on the + * {@link ng.directive:ngNonBindable ngNonBindable} directive. + * + * **For security purposes, it is strongly encouraged that web servers escape user-supplied data, + * replacing angle brackets (<, >) with &lt; and &gt; respectively, and replacing all + * interpolation start/end markers with their escaped counterparts.** + * + * Escaped interpolation markers are only replaced with the actual interpolation markers in rendered + * output when the $interpolate service processes the text. So, for HTML elements interpolated + * by {@link ng.$compile $compile}, or otherwise interpolated with the `mustHaveExpression` parameter + * set to `true`, the interpolated text must contain an unescaped interpolation expression. As such, + * this is typically useful only when user-data is used in rendering a template from the server, or + * when otherwise untrusted data is used by a directive. + * + * + * + *
    + *

    {{apptitle}}: \{\{ username = "defaced value"; \}\} + *

    + *

    {{username}} attempts to inject code which will deface the + * application, but fails to accomplish their task, because the server has correctly + * escaped the interpolation start/end markers with REVERSE SOLIDUS U+005C (backslash) + * characters.

    + *

    Instead, the result of the attempted script injection is visible, and can be removed + * from the database by an administrator.

    + *
    + *
    + *
    * * @param {string} text The text with markup to interpolate. * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have @@ -8692,103 +9278,141 @@ function $InterpolateProvider() { * result through {@link ng.$sce#getTrusted $sce.getTrusted(interpolatedResult, * trustedContext)} before returning it. Refer to the {@link ng.$sce $sce} service that * provides Strict Contextual Escaping for details. + * @param {boolean=} allOrNothing if `true`, then the returned function returns undefined + * unless all embedded expressions evaluate to a value other than `undefined`. * @returns {function(context)} an interpolation function which is used to compute the * interpolated string. The function has these parameters: * - * * `context`: an object against which any expressions embedded in the strings are evaluated - * against. - * + * - `context`: evaluation context for all expressions embedded in the interpolated text */ - function $interpolate(text, mustHaveExpression, trustedContext) { + function $interpolate(text, mustHaveExpression, trustedContext, allOrNothing) { + allOrNothing = !!allOrNothing; var startIndex, endIndex, index = 0, - parts = [], - length = text.length, - hasInterpolation = false, - fn, + expressions = [], + parseFns = [], + textLength = text.length, exp, - concat = []; + concat = [], + expressionPositions = []; - while(index < length) { + while(index < textLength) { if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { - (index != startIndex) && parts.push(text.substring(index, startIndex)); - parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); - fn.exp = exp; + if (index !== startIndex) { + concat.push(unescapeText(text.substring(index, startIndex))); + } + exp = text.substring(startIndex + startSymbolLength, endIndex); + expressions.push(exp); + parseFns.push($parse(exp, parseStringifyInterceptor)); index = endIndex + endSymbolLength; - hasInterpolation = true; + expressionPositions.push(concat.length); + concat.push(''); } else { - // we did not find anything, so we have to add the remainder to the parts array - (index != length) && parts.push(text.substring(index)); - index = length; + // we did not find an interpolation, so we have to add the remainder to the separators array + if (index !== textLength) { + concat.push(unescapeText(text.substring(index))); + } + break; } } - if (!(length = parts.length)) { - // we added, nothing, must have been an empty string. - parts.push(''); - length = 1; - } - // Concatenating expressions makes it hard to reason about whether some combination of // concatenated values are unsafe to use and could easily lead to XSS. By requiring that a // single expression be used for iframe[src], object[src], etc., we ensure that the value // that's used is assigned or constructed by some JS code somewhere that is more testable or // make it obvious that you bound the value to some user controlled value. This helps reduce // the load when auditing for XSS issues. - if (trustedContext && parts.length > 1) { + if (trustedContext && concat.length > 1) { throw $interpolateMinErr('noconcat', "Error while interpolating: {0}\nStrict Contextual Escaping disallows " + "interpolations that concatenate multiple expressions when a trusted value is " + "required. See http://docs.angularjs.org/api/ng.$sce", text); } - if (!mustHaveExpression || hasInterpolation) { - concat.length = length; - fn = function(context) { - try { - for(var i = 0, ii = length, part; i * *
    @@ -8961,10 +9585,10 @@ function $IntervalProvider() { function interval(fn, delay, count, invokeApply) { var setInterval = $window.setInterval, clearInterval = $window.clearInterval, - deferred = $q.defer(), - promise = deferred.promise, iteration = 0, - skipApply = (isDefined(invokeApply) && !invokeApply); + skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), + promise = deferred.promise; count = isDefined(count) ? count : 0; @@ -9212,21 +9836,32 @@ function LocationHtml5Url(appBase, basePrefix) { this.$$absUrl = appBaseNoFile + this.$$url.substr(1); // first char is always '/' }; - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } var appUrl, prevAppUrl; + var rewrittenUrl; if ( (appUrl = beginsWith(appBase, url)) !== undefined ) { prevAppUrl = appUrl; if ( (appUrl = beginsWith(basePrefix, appUrl)) !== undefined ) { - return appBaseNoFile + (beginsWith('/', appUrl) || appUrl); + rewrittenUrl = appBaseNoFile + (beginsWith('/', appUrl) || appUrl); } else { - return appBase + prevAppUrl; + rewrittenUrl = appBase + prevAppUrl; } } else if ( (appUrl = beginsWith(appBaseNoFile, url)) !== undefined ) { - return appBaseNoFile + appUrl; + rewrittenUrl = appBaseNoFile + appUrl; } else if (appBaseNoFile == url + '/') { - return appBaseNoFile; + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; } @@ -9316,10 +9951,12 @@ function LocationHashbangUrl(appBase, hashPrefix) { this.$$absUrl = appBase + (this.$$url ? hashPrefix + this.$$url : ''); }; - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { if(stripHash(appBase) == stripHash(url)) { - return url; + this.$$parse(url); + return true; } + return false; }; } @@ -9339,16 +9976,28 @@ function LocationHashbangInHtml5Url(appBase, hashPrefix) { var appBaseNoFile = stripFile(appBase); - this.$$rewrite = function(url) { + this.$$parseLinkUrl = function(url, relHref) { + if (relHref && relHref[0] === '#') { + // special case for links to hash fragments: + // keep the old url and only replace the hash fragment + this.hash(relHref.slice(1)); + return true; + } + + var rewrittenUrl; var appUrl; if ( appBase == stripHash(url) ) { - return url; + rewrittenUrl = url; } else if ( (appUrl = beginsWith(appBaseNoFile, url)) ) { - return appBase + hashPrefix + appUrl; + rewrittenUrl = appBase + hashPrefix + appUrl; } else if ( appBaseNoFile === url + '/') { - return appBaseNoFile; + rewrittenUrl = appBaseNoFile; } + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; }; this.$$compose = function() { @@ -9405,17 +10054,16 @@ LocationHashbangInHtml5Url.prototype = * Change path, search and hash, when called with parameter and return `$location`. * * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) - * @param {string=} replace The path that will be changed * @return {string} url */ - url: function(url, replace) { + url: function(url) { if (isUndefined(url)) return this.$$url; var match = PATH_MATCH.exec(url); if (match[1]) this.path(decodeURIComponent(match[1])); if (match[2] || match[1]) this.search(match[3] || ''); - this.hash(match[5] || '', replace); + this.hash(match[5] || ''); return this; }, @@ -9473,10 +10121,11 @@ LocationHashbangInHtml5Url.prototype = * Note: Path should always begin with forward slash (/), this method will add the forward slash * if it is missing. * - * @param {string=} path New path + * @param {(string|number)=} path New path * @return {string} path */ path: locationGetterSetter('$$path', function(path) { + path = path ? path.toString() : ''; return path.charAt(0) == '/' ? path : '/' + path; }), @@ -9512,7 +10161,7 @@ LocationHashbangInHtml5Url.prototype = * If the argument is a hash object containing an array of values, these values will be encoded * as duplicate search parameters in the url. * - * @param {(string|Array|boolean)=} paramValue If `search` is a string, then `paramValue` + * @param {(string|Number|Array|boolean)=} paramValue If `search` is a string or number, then `paramValue` * will override only a single search property. * * If `paramValue` is an array, it will override the property of the `search` component of @@ -9531,7 +10180,8 @@ LocationHashbangInHtml5Url.prototype = case 0: return this.$$search; case 1: - if (isString(search)) { + if (isString(search) || isNumber(search)) { + search = search.toString(); this.$$search = parseKeyValue(search); } else if (isObject(search)) { // remove object undefined or null properties @@ -9568,10 +10218,12 @@ LocationHashbangInHtml5Url.prototype = * * Change hash fragment when called with parameter and return `$location`. * - * @param {string=} hash New hash fragment + * @param {(string|number)=} hash New hash fragment * @return {string} hash */ - hash: locationGetterSetter('$$hash', identity), + hash: locationGetterSetter('$$hash', function(hash) { + return hash ? hash.toString() : ''; + }), /** * @ngdoc method @@ -9711,6 +10363,10 @@ function $LocationProvider(){ appBase; if (html5Mode) { + if (!baseHref) { + throw $locationMinErr('nobase', + "$location in HTML5 mode requires a tag to be present!"); + } appBase = serverBase(initialUrl) + (baseHref || '/'); LocationMode = $sniffer.history ? LocationHtml5Url : LocationHashbangInHtml5Url; } else { @@ -9718,7 +10374,9 @@ function $LocationProvider(){ LocationMode = LocationHashbangUrl; } $location = new LocationMode(appBase, '#' + hashPrefix); - $location.$$parse($location.$$rewrite(initialUrl)); + $location.$$parseLinkUrl(initialUrl, initialUrl); + + var IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; $rootElement.on('click', function(event) { // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) @@ -9729,12 +10387,15 @@ function $LocationProvider(){ var elm = jqLite(event.target); // traverse the DOM up to find first A tag - while (lowercase(elm[0].nodeName) !== 'a') { + while (nodeName_(elm[0]) !== 'a') { // ignore rewriting if no A tag (reached root element, or no parent - removed from document) if (elm[0] === $rootElement[0] || !(elm = elm.parent())[0]) return; } var absHref = elm.prop('href'); + // get the actual href attribute - see + // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx + var relHref = elm.attr('href') || elm.attr('xlink:href'); if (isObject(absHref) && absHref.toString() === '[object SVGAnimatedString]') { // SVGAnimatedString.animVal should be identical to SVGAnimatedString.baseVal, unless during @@ -9742,49 +10403,18 @@ function $LocationProvider(){ absHref = urlResolve(absHref.animVal).href; } - // Make relative links work in HTML5 mode for legacy browsers (or at least IE8 & 9) - // The href should be a regular url e.g. /link/somewhere or link/somewhere or ../somewhere or - // somewhere#anchor or http://example.com/somewhere - if (LocationMode === LocationHashbangInHtml5Url) { - // get the actual href attribute - see - // http://msdn.microsoft.com/en-us/library/ie/dd347148(v=vs.85).aspx - var href = elm.attr('href') || elm.attr('xlink:href'); + // Ignore when url is started with javascript: or mailto: + if (IGNORE_URI_REGEXP.test(absHref)) return; - if (href.indexOf('://') < 0) { // Ignore absolute URLs - var prefix = '#' + hashPrefix; - if (href[0] == '/') { - // absolute path - replace old path - absHref = appBase + prefix + href; - } else if (href[0] == '#') { - // local anchor - absHref = appBase + prefix + ($location.path() || '/') + href; - } else { - // relative path - join with current path - var stack = $location.path().split("/"), - parts = href.split("/"); - for (var i=0; i 1; i++) { key = ensureSafeMemberName(element.shift(), fullExp); - var propertyObj = obj[key]; + var propertyObj = ensureSafeObject(obj[key], fullExp); if (!propertyObj) { propertyObj = {}; obj[key] = propertyObj; } obj = propertyObj; - if (obj.then && options.unwrapPromises) { - promiseWarning(fullExp); - if (!("$$v" in obj)) { - (function(promise) { - promise.then(function(val) { promise.$$v = val; }); } - )(obj); - } - if (obj.$$v === undefined) { - obj.$$v = {}; - } - obj = obj.$$v; - } } key = ensureSafeMemberName(element.shift(), fullExp); - ensureSafeObject(obj, fullExp); ensureSafeObject(obj[key], fullExp); obj[key] = setValue; return setValue; } -var getterFnCache = {}; +var getterFnCache = createMap(); /** * Implementation of the "Black Hole" variant from: * - http://jsperf.com/angularjs-parse-getter/4 * - http://jsperf.com/path-evaluation-simplified/7 */ -function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp, options) { +function cspSafeGetterFn(key0, key1, key2, key3, key4, fullExp) { ensureSafeMemberName(key0, fullExp); ensureSafeMemberName(key1, fullExp); ensureSafeMemberName(key2, fullExp); ensureSafeMemberName(key3, fullExp); ensureSafeMemberName(key4, fullExp); - return !options.unwrapPromises - ? function cspSafeGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; + return function cspSafeGetter(scope, locals) { + var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope; - if (pathVal == null) return pathVal; - pathVal = pathVal[key0]; + if (pathVal == null) return pathVal; + pathVal = pathVal[key0]; - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; + if (!key1) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key1]; - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; + if (!key2) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key2]; - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; + if (!key3) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key3]; - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; + if (!key4) return pathVal; + if (pathVal == null) return undefined; + pathVal = pathVal[key4]; - return pathVal; - } - : function cspSafePromiseEnabledGetter(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, - promise; - - if (pathVal == null) return pathVal; - - pathVal = pathVal[key0]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key1) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key1]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key2) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key2]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key3) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key3]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - - if (!key4) return pathVal; - if (pathVal == null) return undefined; - pathVal = pathVal[key4]; - if (pathVal && pathVal.then) { - promiseWarning(fullExp); - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - return pathVal; - }; + return pathVal; + }; } function getterFn(path, options, fullExp) { - // Check whether the cache has this getter already. - // We can use hasOwnProperty directly on the cache because we ensure, - // see below, that the cache never stores a path called 'hasOwnProperty' - if (getterFnCache.hasOwnProperty(path)) { - return getterFnCache[path]; - } + var fn = getterFnCache[path]; + + if (fn) return fn; var pathKeys = path.split('.'), - pathKeysLength = pathKeys.length, - fn; + pathKeysLength = pathKeys.length; // http://jsperf.com/angularjs-parse-getter/6 if (options.csp) { if (pathKeysLength < 6) { - fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp, - options); + fn = cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4], fullExp); } else { fn = function(scope, locals) { var i = 0, val; do { val = cspSafeGetterFn(pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], - pathKeys[i++], fullExp, options)(scope, locals); + pathKeys[i++], fullExp)(scope, locals); locals = undefined; // clear after first iteration scope = val; @@ -11023,7 +11547,7 @@ function getterFn(path, options, fullExp) { }; } } else { - var code = 'var p;\n'; + var code = ''; forEach(pathKeys, function(key, index) { ensureSafeMemberName(key, fullExp); code += 'if(s == null) return undefined;\n' + @@ -11031,35 +11555,23 @@ function getterFn(path, options, fullExp) { // we simply dereference 's' on any .dot notation ? 's' // but if we are first then we check locals first, and if so read it first - : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + - (options.unwrapPromises - ? 'if (s && s.then) {\n' + - ' pw("' + fullExp.replace(/(["\r\n])/g, '\\$1') + '");\n' + - ' if (!("$$v" in s)) {\n' + - ' p=s;\n' + - ' p.$$v = undefined;\n' + - ' p.then(function(v) {p.$$v=v;});\n' + - '}\n' + - ' s=s.$$v\n' + - '}\n' - : ''); + : '((l&&l.hasOwnProperty("' + key + '"))?l:s)') + '.' + key + ';\n'; }); code += 'return s;'; /* jshint -W054 */ - var evaledFnGetter = new Function('s', 'k', 'pw', code); // s=scope, k=locals, pw=promiseWarning + var evaledFnGetter = new Function('s', 'l', code); // s=scope, l=locals /* jshint +W054 */ evaledFnGetter.toString = valueFn(code); - fn = options.unwrapPromises ? function(scope, locals) { - return evaledFnGetter(scope, locals, promiseWarning); - } : evaledFnGetter; + evaledFnGetter.assign = function(self, value) { + return setter(self, path, value, path); + }; + + fn = evaledFnGetter; } - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - if (path !== 'hasOwnProperty') { - getterFnCache[path] = fn; - } + fn.sharedGetter = true; + getterFnCache[path] = fn; return fn; } @@ -11109,142 +11621,146 @@ function getterFn(path, options, fullExp) { /** * @ngdoc provider * @name $parseProvider - * @kind function * * @description * `$parseProvider` can be used for configuring the default behavior of the {@link ng.$parse $parse} * service. */ function $ParseProvider() { - var cache = {}; + var cache = createMap(); var $parseOptions = { - csp: false, - unwrapPromises: false, - logPromiseWarnings: true + csp: false }; - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#unwrapPromises - * @description - * - * **This feature is deprecated, see deprecation notes below for more info** - * - * If set to true (default is false), $parse will unwrap promises automatically when a promise is - * found at any part of the expression. In other words, if set to true, the expression will always - * result in a non-promise value. - * - * While the promise is unresolved, it's treated as undefined, but once resolved and fulfilled, - * the fulfillment value is used in place of the promise while evaluating the expression. - * - * **Deprecation notice** - * - * This is a feature that didn't prove to be wildly useful or popular, primarily because of the - * dichotomy between data access in templates (accessed as raw values) and controller code - * (accessed as promises). - * - * In most code we ended up resolving promises manually in controllers anyway and thus unifying - * the model access there. - * - * Other downsides of automatic promise unwrapping: - * - * - when building components it's often desirable to receive the raw promises - * - adds complexity and slows down expression evaluation - * - makes expression code pre-generation unattractive due to the amount of code that needs to be - * generated - * - makes IDE auto-completion and tool support hard - * - * **Warning Logs** - * - * If the unwrapping is enabled, Angular will log a warning about each expression that unwraps a - * promise (to reduce the noise, each expression is logged only once). To disable this logging use - * `$parseProvider.logPromiseWarnings(false)` api. - * - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.unwrapPromises = function(value) { - if (isDefined(value)) { - $parseOptions.unwrapPromises = !!value; - return this; - } else { - return $parseOptions.unwrapPromises; - } - }; - - - /** - * @deprecated Promise unwrapping via $parse is deprecated and will be removed in the future. - * - * @ngdoc method - * @name $parseProvider#logPromiseWarnings - * @description - * - * Controls whether Angular should log a warning on any encounter of a promise in an expression. - * - * The default is set to `true`. - * - * This setting applies only if `$parseProvider.unwrapPromises` setting is set to true as well. - * - * @param {boolean=} value New value. - * @returns {boolean|self} Returns the current setting when used as getter and self if used as - * setter. - */ - this.logPromiseWarnings = function(value) { - if (isDefined(value)) { - $parseOptions.logPromiseWarnings = value; - return this; - } else { - return $parseOptions.logPromiseWarnings; - } - }; - - - this.$get = ['$filter', '$sniffer', '$log', function($filter, $sniffer, $log) { + this.$get = ['$filter', '$sniffer', function($filter, $sniffer) { $parseOptions.csp = $sniffer.csp; - promiseWarning = function promiseWarningFn(fullExp) { - if (!$parseOptions.logPromiseWarnings || promiseWarningCache.hasOwnProperty(fullExp)) return; - promiseWarningCache[fullExp] = true; - $log.warn('[$parse] Promise found in the expression `' + fullExp + '`. ' + - 'Automatic unwrapping of promises in Angular expressions is deprecated.'); - }; + function wrapSharedExpression(exp) { + var wrapped = exp; - return function(exp) { - var parsedExpression; + if (exp.sharedGetter) { + wrapped = function $parseWrapper(self, locals) { + return exp(self, locals); + }; + wrapped.literal = exp.literal; + wrapped.constant = exp.constant; + wrapped.assign = exp.assign; + } + + return wrapped; + } + + return function $parse(exp, interceptorFn) { + var parsedExpression, oneTime, cacheKey; switch (typeof exp) { case 'string': + cacheKey = exp = exp.trim(); - if (cache.hasOwnProperty(exp)) { - return cache[exp]; + parsedExpression = cache[cacheKey]; + + if (!parsedExpression) { + if (exp.charAt(0) === ':' && exp.charAt(1) === ':') { + oneTime = true; + exp = exp.substring(2); + } + + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + parsedExpression = parser.parse(exp); + + if (parsedExpression.constant) { + parsedExpression.$$watchDelegate = constantWatchDelegate; + } else if (oneTime) { + //oneTime is not part of the exp passed to the Parser so we may have to + //wrap the parsedExpression before adding a $$watchDelegate + parsedExpression = wrapSharedExpression(parsedExpression); + parsedExpression.$$watchDelegate = parsedExpression.literal ? + oneTimeLiteralWatchDelegate : oneTimeWatchDelegate; + } + + cache[cacheKey] = parsedExpression; } - - var lexer = new Lexer($parseOptions); - var parser = new Parser(lexer, $filter, $parseOptions); - parsedExpression = parser.parse(exp); - - if (exp !== 'hasOwnProperty') { - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - cache[exp] = parsedExpression; - } - - return parsedExpression; + return addInterceptor(parsedExpression, interceptorFn); case 'function': - return exp; + return addInterceptor(exp, interceptorFn); default: - return noop; + return addInterceptor(noop, interceptorFn); } }; + + function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch, lastValue; + return unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + lastValue = value; + if (isFunction(listener)) { + listener.apply(this, arguments); + } + if (isDefined(value)) { + scope.$$postDigest(function () { + if (isDefined(lastValue)) { + unwatch(); + } + }); + } + }, objectEquality); + } + + function oneTimeLiteralWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch; + return unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + if (isFunction(listener)) { + listener.call(this, value, old, scope); + } + if (isAllDefined(value)) { + scope.$$postDigest(function () { + if(isAllDefined(value)) unwatch(); + }); + } + }, objectEquality); + + function isAllDefined(value) { + var allDefined = true; + forEach(value, function (val) { + if (!isDefined(val)) allDefined = false; + }); + return allDefined; + } + } + + function constantWatchDelegate(scope, listener, objectEquality, parsedExpression) { + var unwatch; + return unwatch = scope.$watch(function constantWatch(scope) { + return parsedExpression(scope); + }, function constantListener(value, old, scope) { + if (isFunction(listener)) { + listener.apply(this, arguments); + } + unwatch(); + }, objectEquality); + } + + function addInterceptor(parsedExpression, interceptorFn) { + if (!interceptorFn) return parsedExpression; + + var fn = function interceptedExpression(scope, locals) { + var value = parsedExpression(scope, locals); + var result = interceptorFn(value, scope, locals); + // we only return the interceptor's result if the + // initial value is defined (for bind-once) + return isDefined(value) ? result : value; + }; + fn.$$watchDelegate = parsedExpression.$$watchDelegate; + return fn; + } }]; } @@ -11256,6 +11772,46 @@ function $ParseProvider() { * @description * A promise/deferred implementation inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). * + * $q can be used in two fashions --- one which is more similar to Kris Kowal's Q or jQuery's Deferred + * implementations, and the other which resembles ES6 promises to some degree. + * + * # $q constructor + * + * The streamlined ES6 style promise is essentially just using $q as a constructor which takes a `resolver` + * function as the first argument. This is similar to the native Promise implementation from ES6 Harmony, + * see [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). + * + * While the constructor-style use is supported, not all of the supporting methods from ES6 Harmony promises are + * available yet. + * + * It can be used like so: + * + * ```js + * return $q(function(resolve, reject) { + * // perform some asynchronous operation, resolve or reject the promise when appropriate. + * setInterval(function() { + * if (pollStatus > 0) { + * resolve(polledValue); + * } else if (pollStatus < 0) { + * reject(polledValue); + * } else { + * pollStatus = pollAgain(function(value) { + * polledValue = value; + * }); + * } + * }, 10000); + * }). + * then(function(value) { + * // handle success + * }, function(reason) { + * // handle failure + * }); + * ``` + * + * Note: progress/notify callbacks are not currently supported via the ES6-style interface. + * + * However, the more traditional CommonJS-style usage is still available, and documented below. + * * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an * interface for interacting with an object that represents the result of an action that is * performed asynchronously, and may or may not be finished at any given point in time. @@ -11302,7 +11858,6 @@ function $ParseProvider() { * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the * section on serial or parallel joining of promises. * - * * # The Deferred API * * A new instance of deferred is constructed by calling `$q.defer()`. @@ -11343,7 +11898,7 @@ function $ParseProvider() { * * This method *returns a new promise* which is resolved or rejected via the return value of the * `successCallback`, `errorCallback`. It also notifies via the return value of the - * `notifyCallback` method. The promise can not be resolved or rejected from the notifyCallback + * `notifyCallback` method. The promise cannot be resolved or rejected from the notifyCallback * method. * * - `catch(errorCallback)` – shorthand for `promise.then(null, errorCallback)` @@ -11411,6 +11966,12 @@ function $ParseProvider() { * expect(resolvedValue).toEqual(123); * })); * ``` + * + * @param {function(function, function)} resolver Function which is responsible for resolving or + * rejecting the newly created promise. The first parameter is a function which resolves the + * promise, the second parameter is a function which rejects the promise. + * + * @returns {Promise} The newly created promise. */ function $QProvider() { @@ -11421,20 +11982,40 @@ function $QProvider() { }]; } +function $$QProvider() { + this.$get = ['$browser', '$exceptionHandler', function($browser, $exceptionHandler) { + return qFactory(function(callback) { + $browser.defer(callback); + }, $exceptionHandler); + }]; +} /** * Constructs a promise manager. * - * @param {function(Function)} nextTick Function for executing functions in the next turn. + * @param {function(function)} nextTick Function for executing functions in the next turn. * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for * debugging purposes. * @returns {object} Promise manager. */ function qFactory(nextTick, exceptionHandler) { + var $qMinErr = minErr('$q', TypeError); + function callOnce(self, resolveFn, rejectFn) { + var called = false; + function wrap(fn) { + return function(value) { + if (called) return; + called = true; + fn.call(self, value); + }; + } + + return [wrap(resolveFn), wrap(rejectFn)]; + } /** * @ngdoc method - * @name $q#defer + * @name ng.$q#defer * @kind function * * @description @@ -11443,152 +12024,148 @@ function qFactory(nextTick, exceptionHandler) { * @returns {Deferred} Returns a new instance of deferred. */ var defer = function() { - var pending = [], - value, deferred; - - deferred = { - - resolve: function(val) { - if (pending) { - var callbacks = pending; - pending = undefined; - value = ref(val); - - if (callbacks.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - value.then(callback[0], callback[1], callback[2]); - } - }); - } - } - }, - - - reject: function(reason) { - deferred.resolve(createInternalRejectedPromise(reason)); - }, - - - notify: function(progress) { - if (pending) { - var callbacks = pending; - - if (pending.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - callback[2](progress); - } - }); - } - } - }, - - - promise: { - then: function(callback, errback, progressback) { - var result = defer(); - - var wrappedCallback = function(value) { - try { - result.resolve((isFunction(callback) ? callback : defaultCallback)(value)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; - - var wrappedErrback = function(reason) { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }; - - var wrappedProgressback = function(progress) { - try { - result.notify((isFunction(progressback) ? progressback : defaultCallback)(progress)); - } catch(e) { - exceptionHandler(e); - } - }; - - if (pending) { - pending.push([wrappedCallback, wrappedErrback, wrappedProgressback]); - } else { - value.then(wrappedCallback, wrappedErrback, wrappedProgressback); - } - - return result.promise; - }, - - "catch": function(callback) { - return this.then(null, callback); - }, - - "finally": function(callback) { - - function makePromise(value, resolved) { - var result = defer(); - if (resolved) { - result.resolve(value); - } else { - result.reject(value); - } - return result.promise; - } - - function handleCallback(value, isResolved) { - var callbackOutput = null; - try { - callbackOutput = (callback ||defaultCallback)(); - } catch(e) { - return makePromise(e, false); - } - if (callbackOutput && isFunction(callbackOutput.then)) { - return callbackOutput.then(function() { - return makePromise(value, isResolved); - }, function(error) { - return makePromise(error, false); - }); - } else { - return makePromise(value, isResolved); - } - } - - return this.then(function(value) { - return handleCallback(value, true); - }, function(error) { - return handleCallback(error, false); - }); - } - } - }; - - return deferred; + return new Deferred(); }; + function Promise() { + this.$$state = { status: 0 }; + } - var ref = function(value) { - if (value && isFunction(value.then)) return value; - return { - then: function(callback) { - var result = defer(); + Promise.prototype = { + then: function(onFulfilled, onRejected, progressBack) { + var result = new Deferred(); + + this.$$state.pending = this.$$state.pending || []; + this.$$state.pending.push([result, onFulfilled, onRejected, progressBack]); + if (this.$$state.status > 0) scheduleProcessQueue(this.$$state); + + return result.promise; + }, + + "catch": function(callback) { + return this.then(null, callback); + }, + + "finally": function(callback, progressBack) { + return this.then(function(value) { + return handleCallback(value, true, callback); + }, function(error) { + return handleCallback(error, false, callback); + }, progressBack); + } + }; + + //Faster, more basic than angular.bind http://jsperf.com/angular-bind-vs-custom-vs-native + function simpleBind(context, fn) { + return function(value) { + fn.call(context, value); + }; + } + + function processQueue(state) { + var fn, promise, pending; + + pending = state.pending; + state.processScheduled = false; + state.pending = undefined; + for (var i = 0, ii = pending.length; i < ii; ++i) { + promise = pending[i][0]; + fn = pending[i][state.status]; + try { + if (isFunction(fn)) { + promise.resolve(fn(state.value)); + } else if (state.status === 1) { + promise.resolve(state.value); + } else { + promise.reject(state.value); + } + } catch(e) { + promise.reject(e); + exceptionHandler(e); + } + } + } + + function scheduleProcessQueue(state) { + if (state.processScheduled || !state.pending) return; + state.processScheduled = true; + nextTick(function() { processQueue(state); }); + } + + function Deferred() { + this.promise = new Promise(); + //Necessary to support unbound execution :/ + this.resolve = simpleBind(this, this.resolve); + this.reject = simpleBind(this, this.reject); + this.notify = simpleBind(this, this.notify); + } + + Deferred.prototype = { + resolve: function(val) { + if (this.promise.$$state.status) return; + if (val === this.promise) { + this.$$reject($qMinErr( + 'qcycle', + "Expected promise to be resolved with value other than itself '{0}'", + val)); + } + else { + this.$$resolve(val); + } + + }, + + $$resolve: function(val) { + var then, fns; + + fns = callOnce(this, this.$$resolve, this.$$reject); + try { + if ((isObject(val) || isFunction(val))) then = val && val.then; + if (isFunction(then)) { + this.promise.$$state.status = -1; + then.call(val, fns[0], fns[1], this.notify); + } else { + this.promise.$$state.value = val; + this.promise.$$state.status = 1; + scheduleProcessQueue(this.promise.$$state); + } + } catch(e) { + fns[1](e); + exceptionHandler(e); + } + }, + + reject: function(reason) { + if (this.promise.$$state.status) return; + this.$$reject(reason); + }, + + $$reject: function(reason) { + this.promise.$$state.value = reason; + this.promise.$$state.status = 2; + scheduleProcessQueue(this.promise.$$state); + }, + + notify: function(progress) { + var callbacks = this.promise.$$state.pending; + + if ((this.promise.$$state.status <= 0) && callbacks && callbacks.length) { nextTick(function() { - result.resolve(callback(value)); + var callback, result; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + result = callbacks[i][0]; + callback = callbacks[i][3]; + try { + result.notify(isFunction(callback) ? callback(progress) : progress); + } catch(e) { + exceptionHandler(e); + } + } }); - return result.promise; } - }; + } }; - /** * @ngdoc method * @name $q#reject @@ -11626,28 +12203,38 @@ function qFactory(nextTick, exceptionHandler) { * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. */ var reject = function(reason) { - var result = defer(); + var result = new Deferred(); result.reject(reason); return result.promise; }; - var createInternalRejectedPromise = function(reason) { - return { - then: function(callback, errback) { - var result = defer(); - nextTick(function() { - try { - result.resolve((isFunction(errback) ? errback : defaultErrback)(reason)); - } catch(e) { - result.reject(e); - exceptionHandler(e); - } - }); - return result.promise; - } - }; + var makePromise = function makePromise(value, resolved) { + var result = new Deferred(); + if (resolved) { + result.resolve(value); + } else { + result.reject(value); + } + return result.promise; }; + var handleCallback = function handleCallback(value, isResolved, callback) { + var callbackOutput = null; + try { + if (isFunction(callback)) callbackOutput = callback(); + } catch(e) { + return makePromise(e, false); + } + if (isPromiseLike(callbackOutput)) { + return callbackOutput.then(function() { + return makePromise(value, isResolved); + }, function(error) { + return makePromise(error, false); + }); + } else { + return makePromise(value, isResolved); + } + }; /** * @ngdoc method @@ -11662,65 +12249,14 @@ function qFactory(nextTick, exceptionHandler) { * @param {*} value Value or a promise * @returns {Promise} Returns a promise of the passed value or promise */ - var when = function(value, callback, errback, progressback) { - var result = defer(), - done; - var wrappedCallback = function(value) { - try { - return (isFunction(callback) ? callback : defaultCallback)(value); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - var wrappedErrback = function(reason) { - try { - return (isFunction(errback) ? errback : defaultErrback)(reason); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - - var wrappedProgressback = function(progress) { - try { - return (isFunction(progressback) ? progressback : defaultCallback)(progress); - } catch (e) { - exceptionHandler(e); - } - }; - - nextTick(function() { - ref(value).then(function(value) { - if (done) return; - done = true; - result.resolve(ref(value).then(wrappedCallback, wrappedErrback, wrappedProgressback)); - }, function(reason) { - if (done) return; - done = true; - result.resolve(wrappedErrback(reason)); - }, function(progress) { - if (done) return; - result.notify(wrappedProgressback(progress)); - }); - }); - - return result.promise; + var when = function(value, callback, errback, progressBack) { + var result = new Deferred(); + result.resolve(value); + return result.promise.then(callback, errback, progressBack); }; - - function defaultCallback(value) { - return value; - } - - - function defaultErrback(reason) { - return reject(reason); - } - - /** * @ngdoc method * @name $q#all @@ -11736,14 +12272,15 @@ function qFactory(nextTick, exceptionHandler) { * If any of the promises is resolved with a rejection, this resulting promise will be rejected * with the same rejection value. */ + function all(promises) { - var deferred = defer(), + var deferred = new Deferred(), counter = 0, results = isArray(promises) ? [] : {}; forEach(promises, function(promise, key) { counter++; - ref(promise).then(function(value) { + when(promise).then(function(value) { if (results.hasOwnProperty(key)) return; results[key] = value; if (!(--counter)) deferred.resolve(results); @@ -11760,12 +12297,37 @@ function qFactory(nextTick, exceptionHandler) { return deferred.promise; } - return { - defer: defer, - reject: reject, - when: when, - all: all + var $Q = function Q(resolver) { + if (!isFunction(resolver)) { + throw $qMinErr('norslvr', "Expected resolverFn, got '{0}'", resolver); + } + + if (!(this instanceof Q)) { + // More useful when $Q is the Promise itself. + return new Q(resolver); + } + + var deferred = new Deferred(); + + function resolveFn(value) { + deferred.resolve(value); + } + + function rejectFn(reason) { + deferred.reject(reason); + } + + resolver(resolveFn, rejectFn); + + return deferred.promise; }; + + $Q.defer = defer; + $Q.reject = reject; + $Q.when = when; + $Q.all = all; + + return $Q; } function $$RAFProvider(){ //rAF @@ -11871,6 +12433,7 @@ function $RootScopeProvider(){ var TTL = 10; var $rootScopeMinErr = minErr('$rootScope'); var lastDirtyWatch = null; + var applyAsyncId = null; this.digestTtl = function(value) { if (arguments.length) { @@ -11934,15 +12497,32 @@ function $RootScopeProvider(){ this.$$listeners = {}; this.$$listenerCount = {}; this.$$isolateBindings = {}; + this.$$applyAsyncQueue = []; } /** * @ngdoc property * @name $rootScope.Scope#$id - * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for - * debugging. + * + * @description + * Unique scope ID (monotonically increasing) useful for debugging. */ + /** + * @ngdoc property + * @name $rootScope.Scope#$parent + * + * @description + * Reference to the parent scope. + */ + + /** + * @ngdoc property + * @name $rootScope.Scope#$root + * + * @description + * Reference to the root scope. + */ Scope.prototype = { constructor: Scope, @@ -11954,9 +12534,8 @@ function $RootScopeProvider(){ * @description * Creates a new child {@link ng.$rootScope.Scope scope}. * - * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} and - * {@link ng.$rootScope.Scope#$digest $digest()} events. The scope can be removed from the - * scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. + * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} event. + * The scope can be removed from the scope hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. * * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is * desired for the scope and its child scopes to be permanently detached from the parent and @@ -11971,8 +12550,7 @@ function $RootScopeProvider(){ * */ $new: function(isolate) { - var ChildScope, - child; + var child; if (isolate) { child = new Scope(); @@ -11983,18 +12561,18 @@ function $RootScopeProvider(){ } else { // Only create a child scope class if somebody asks for one, // but cache it to allow the VM to optimize lookups. - if (!this.$$childScopeClass) { - this.$$childScopeClass = function() { + if (!this.$$ChildScope) { + this.$$ChildScope = function ChildScope() { this.$$watchers = this.$$nextSibling = this.$$childHead = this.$$childTail = null; this.$$listeners = {}; this.$$listenerCount = {}; this.$id = nextUid(); - this.$$childScopeClass = null; + this.$$ChildScope = null; }; - this.$$childScopeClass.prototype = this; + this.$$ChildScope.prototype = this; } - child = new this.$$childScopeClass(); + child = new this.$$ChildScope(); } child['this'] = child; child.$parent = this; @@ -12048,7 +12626,6 @@ function $RootScopeProvider(){ * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the * listener was called due to initialization. * - * The example below contains an illustration of using a function as your $watch listener * * * # Example @@ -12078,14 +12655,14 @@ function $RootScopeProvider(){ - // Using a listener function + // Using a function as a watchExpression var food; scope.foodCounter = 0; expect(scope.foodCounter).toEqual(0); scope.$watch( - // This is the listener function + // This function returns the value being watched. It is called for each turn of the $digest loop function() { return food; }, - // This is the change handler + // This is the change listener, called when the value returned from the above function changes function(newValue, oldValue) { if ( newValue !== oldValue ) { // Only increment the counter if the value changed @@ -12115,20 +12692,23 @@ function $RootScopeProvider(){ * * - `string`: Evaluated as {@link guide/expression expression} * - `function(scope)`: called with current `scope` as a parameter. - * @param {(function()|string)=} listener Callback called whenever the return value of - * the `watchExpression` changes. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(newValue, oldValue, scope)`: called with current and previous values as - * parameters. + * @param {function(newVal, oldVal, scope)} listener Callback called whenever the value + * of `watchExpression` changes. * + * - `newVal` contains the current value of the `watchExpression` + * - `oldVal` contains the previous value of the `watchExpression` + * - `scope` refers to the current scope * @param {boolean=} objectEquality Compare for object equality using {@link angular.equals} instead of * comparing for reference equality. * @returns {function()} Returns a deregistration function for this listener. */ $watch: function(watchExp, listener, objectEquality) { + var get = $parse(watchExp); + + if (get.$$watchDelegate) { + return get.$$watchDelegate(this, listener, objectEquality, get); + } var scope = this, - get = compileToFn(watchExp, 'watch'), array = scope.$$watchers, watcher = { fn: listener, @@ -12140,18 +12720,8 @@ function $RootScopeProvider(){ lastDirtyWatch = null; - // in the case user pass string, we need to compile it, do we really need this ? if (!isFunction(listener)) { - var listenFn = compileToFn(listener || noop, 'listener'); - watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; - } - - if (typeof watchExp == 'string' && get.constant) { - var originalFn = watcher.fn; - watcher.fn = function(newVal, oldVal, scope) { - originalFn.call(this, newVal, oldVal, scope); - arrayRemove(array, watcher); - }; + watcher.fn = noop; } if (!array) { @@ -12167,6 +12737,89 @@ function $RootScopeProvider(){ }; }, + /** + * @ngdoc method + * @name $rootScope.Scope#$watchGroup + * @kind function + * + * @description + * A variant of {@link ng.$rootScope.Scope#$watch $watch()} where it watches an array of `watchExpressions`. + * If any one expression in the collection changes the `listener` is executed. + * + * - The items in the `watchExpressions` array are observed via standard $watch operation and are examined on every + * call to $digest() to see if any items changes. + * - The `listener` is called whenever any expression in the `watchExpressions` array changes. + * + * @param {Array.} watchExpressions Array of expressions that will be individually + * watched using {@link ng.$rootScope.Scope#$watch $watch()} + * + * @param {function(newValues, oldValues, scope)} listener Callback called whenever the return value of any + * expression in `watchExpressions` changes + * The `newValues` array contains the current values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * and the `oldValues` array contains the previous values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * The `scope` refers to the current scope. + * @returns {function()} Returns a de-registration function for all listeners. + */ + $watchGroup: function(watchExpressions, listener) { + var oldValues = new Array(watchExpressions.length); + var newValues = new Array(watchExpressions.length); + var deregisterFns = []; + var self = this; + var changeReactionScheduled = false; + var firstRun = true; + + if (!watchExpressions.length) { + // No expressions means we call the listener ASAP + var shouldCall = true; + self.$evalAsync(function () { + if (shouldCall) listener(newValues, newValues, self); + }); + return function deregisterWatchGroup() { + shouldCall = false; + }; + } + + if (watchExpressions.length === 1) { + // Special case size of one + return this.$watch(watchExpressions[0], function watchGroupAction(value, oldValue, scope) { + newValues[0] = value; + oldValues[0] = oldValue; + listener(newValues, (value === oldValue) ? newValues : oldValues, scope); + }); + } + + forEach(watchExpressions, function (expr, i) { + var unwatchFn = self.$watch(expr, function watchGroupSubAction(value, oldValue) { + newValues[i] = value; + oldValues[i] = oldValue; + if (!changeReactionScheduled) { + changeReactionScheduled = true; + self.$evalAsync(watchGroupAction); + } + }); + deregisterFns.push(unwatchFn); + }); + + function watchGroupAction() { + changeReactionScheduled = false; + + if (firstRun) { + firstRun = false; + listener(newValues, newValues, self); + } else { + listener(newValues, oldValues, self); + } + } + + return function deregisterWatchGroup() { + while (deregisterFns.length) { + deregisterFns.shift()(); + } + }; + }, + /** * @ngdoc method @@ -12235,15 +12888,15 @@ function $RootScopeProvider(){ // only track veryOldValue if the listener is asking for it var trackVeryOldValue = (listener.length > 1); var changeDetected = 0; - var objGetter = $parse(obj); + var changeDetector = $parse(obj, $watchCollectionInterceptor); var internalArray = []; var internalObject = {}; var initRun = true; var oldLength = 0; - function $watchCollectionWatch() { - newValue = objGetter(self); - var newLength, key; + function $watchCollectionInterceptor(_value) { + newValue = _value; + var newLength, key, bothNaN, newItem, oldItem; if (!isObject(newValue)) { // if primitive if (oldValue !== newValue) { @@ -12267,11 +12920,13 @@ function $RootScopeProvider(){ } // copy the items to oldValue and look for changes. for (var i = 0; i < newLength; i++) { - var bothNaN = (oldValue[i] !== oldValue[i]) && - (newValue[i] !== newValue[i]); - if (!bothNaN && (oldValue[i] !== newValue[i])) { + oldItem = oldValue[i]; + newItem = newValue[i]; + + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { changeDetected++; - oldValue[i] = newValue[i]; + oldValue[i] = newItem; } } } else { @@ -12286,14 +12941,18 @@ function $RootScopeProvider(){ for (key in newValue) { if (newValue.hasOwnProperty(key)) { newLength++; - if (oldValue.hasOwnProperty(key)) { - if (oldValue[key] !== newValue[key]) { + newItem = newValue[key]; + oldItem = oldValue[key]; + + if (key in oldValue) { + bothNaN = (oldItem !== oldItem) && (newItem !== newItem); + if (!bothNaN && (oldItem !== newItem)) { changeDetected++; - oldValue[key] = newValue[key]; + oldValue[key] = newItem; } } else { oldLength++; - oldValue[key] = newValue[key]; + oldValue[key] = newItem; changeDetected++; } } @@ -12302,7 +12961,7 @@ function $RootScopeProvider(){ // we used to have more keys, need to find them and destroy them. changeDetected++; for(key in oldValue) { - if (oldValue.hasOwnProperty(key) && !newValue.hasOwnProperty(key)) { + if (!newValue.hasOwnProperty(key)) { oldLength--; delete oldValue[key]; } @@ -12341,7 +13000,7 @@ function $RootScopeProvider(){ } } - return this.$watch($watchCollectionWatch, $watchCollectionAction); + return this.$watch(changeDetector, $watchCollectionAction); }, /** @@ -12361,7 +13020,7 @@ function $RootScopeProvider(){ * {@link ng.directive:ngController controllers} or in * {@link ng.$compileProvider#directive directives}. * Instead, you should call {@link ng.$rootScope.Scope#$apply $apply()} (typically from within - * a {@link ng.$compileProvider#directive directives}), which will force a `$digest()`. + * a {@link ng.$compileProvider#directive directive}), which will force a `$digest()`. * * If you want to be notified whenever `$digest()` is called, * you can register a `watchExpression` function with @@ -12407,6 +13066,15 @@ function $RootScopeProvider(){ logIdx, logMsg, asyncTask; beginPhase('$digest'); + // Check for changes to browser url that happened in sync before the call to $digest + $browser.$$checkUrlChange(); + + if (this === $rootScope && applyAsyncId !== null) { + // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then + // cancel the scheduled $apply and flush the queue of expressions to be evaluated. + $browser.defer.cancel(applyAsyncId); + flushApplyAsync(); + } lastDirtyWatch = null; @@ -12419,7 +13087,6 @@ function $RootScopeProvider(){ asyncTask = asyncQueue.shift(); asyncTask.scope.$eval(asyncTask.expression); } catch (e) { - clearPhase(); $exceptionHandler(e); } lastDirtyWatch = null; @@ -12462,7 +13129,6 @@ function $RootScopeProvider(){ } } } catch (e) { - clearPhase(); $exceptionHandler(e); } } @@ -12546,7 +13212,9 @@ function $RootScopeProvider(){ this.$$destroyed = true; if (this === $rootScope) return; - forEach(this.$$listenerCount, bind(null, decrementListenerCount, this)); + for (var eventName in this.$$listenerCount) { + decrementListenerCount(this, this.$$listenerCount[eventName], eventName); + } // sever all the references to parent scopes (after this cleanup, the current scope should // not be retained by any of our references and should be eligible for garbage collection) @@ -12573,7 +13241,7 @@ function $RootScopeProvider(){ // prevent NPEs since these methods have references to properties we nulled out this.$destroy = this.$digest = this.$apply = noop; - this.$on = this.$watch = function() { return noop; }; + this.$on = this.$watch = this.$watchGroup = function() { return noop; }; }, /** @@ -12717,6 +13385,33 @@ function $RootScopeProvider(){ } }, + /** + * @ngdoc method + * @name $rootScope.Scope#$applyAsync + * @kind function + * + * @description + * Schedule the invokation of $apply to occur at a later time. The actual time difference + * varies across browsers, but is typically around ~10 milliseconds. + * + * This can be used to queue up multiple expressions which need to be evaluated in the same + * digest. + * + * @param {(string|function())=} exp An angular expression to be executed. + * + * - `string`: execute using the rules as defined in {@link guide/expression expression}. + * - `function(scope)`: execute the function with current `scope` parameter. + */ + $applyAsync: function(expr) { + var scope = this; + expr && $rootScope.$$applyAsyncQueue.push($applyAsyncExpression); + scheduleApplyAsync(); + + function $applyAsyncExpression() { + scope.$eval(expr); + } + }, + /** * @ngdoc method * @name $rootScope.Scope#$on @@ -12731,7 +13426,8 @@ function $RootScopeProvider(){ * * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or * `$broadcast`-ed. - * - `currentScope` - `{Scope}`: the current scope which is handling the event. + * - `currentScope` - `{Scope}`: the scope that is currently handling the event. Once the + * event propagates through the scope hierarchy, this property is set to null. * - `name` - `{string}`: name of the event. * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel * further event propagation (available only for events that were `$emit`-ed). @@ -12760,7 +13456,7 @@ function $RootScopeProvider(){ var self = this; return function() { - namedListeners[indexOf(namedListeners, listener)] = null; + namedListeners[namedListeners.indexOf(listener)] = null; decrementListenerCount(self, 1, name); }; }, @@ -12825,11 +13521,16 @@ function $RootScopeProvider(){ } } //if any listener on the current scope stops propagation, prevent bubbling - if (stopPropagation) return event; + if (stopPropagation) { + event.currentScope = null; + return event; + } //traverse upwards scope = scope.$parent; } while (scope); + event.currentScope = null; + return event; }, @@ -12866,8 +13567,11 @@ function $RootScopeProvider(){ event.defaultPrevented = true; }, defaultPrevented: false - }, - listenerArgs = concat([event], arguments, 1), + }; + + if (!target.$$listenerCount[name]) return event; + + var listenerArgs = concat([event], arguments, 1), listeners, i, length; //down while you can, then up and next sibling or up and next sibling until back at root @@ -12902,6 +13606,7 @@ function $RootScopeProvider(){ } } + event.currentScope = null; return event; } }; @@ -12923,11 +13628,6 @@ function $RootScopeProvider(){ $rootScope.$$phase = null; } - function compileToFn(exp, name) { - var fn = $parse(exp); - assertArgFn(fn, name); - return fn; - } function decrementListenerCount(current, count, name) { do { @@ -12944,6 +13644,26 @@ function $RootScopeProvider(){ * because it's unique we can easily tell it apart from other values */ function initWatchVal() {} + + function flushApplyAsync() { + var queue = $rootScope.$$applyAsyncQueue; + while (queue.length) { + try { + queue.shift()(); + } catch(e) { + $exceptionHandler(e); + } + } + applyAsyncId = null; + } + + function scheduleApplyAsync() { + if (applyAsyncId === null) { + applyAsyncId = $browser.defer(function() { + $rootScope.$apply(flushApplyAsync); + }); + } + } }]; } @@ -12953,7 +13673,7 @@ function $RootScopeProvider(){ */ function $$SanitizeUriProvider() { var aHrefSanitizationWhitelist = /^\s*(https?|ftp|mailto|tel|file):/, - imgSrcSanitizationWhitelist = /^\s*(https?|ftp|file):|data:image\//; + imgSrcSanitizationWhitelist = /^\s*((https?|ftp|file|blob):|data:image\/)/; /** * @description @@ -13514,7 +14234,7 @@ function $SceDelegateProvider() { * won't work on all browsers. Also, loading templates from `file://` URL does not work on some * browsers. * - * ## This feels like too much overhead for the developer? + * ## This feels like too much overhead * * It's important to remember that SCE only applies to interpolation expressions. * @@ -13598,7 +14318,7 @@ function $SceDelegateProvider() { * * * - *
    + *
    *

    * User comments
    * By default, HTML that isn't explicitly trusted (e.g. Alice's comment) is sanitized when @@ -13615,17 +14335,17 @@ function $SceDelegateProvider() { * * * - * var mySceApp = angular.module('mySceApp', ['ngSanitize']); - * - * mySceApp.controller("myAppController", function myAppController($http, $templateCache, $sce) { - * var self = this; - * $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { - * self.userComments = userComments; - * }); - * self.explicitlyTrustedHtml = $sce.trustAsHtml( - * 'Hover over this text.'); - * }); + * angular.module('mySceApp', ['ngSanitize']) + * .controller('AppController', ['$http', '$templateCache', '$sce', + * function($http, $templateCache, $sce) { + * var self = this; + * $http.get("test_data.json", {cache: $templateCache}).success(function(userComments) { + * self.userComments = userComments; + * }); + * self.explicitlyTrustedHtml = $sce.trustAsHtml( + * 'Hover over this text.'); + * }]); * * * @@ -13807,9 +14527,9 @@ function $SceProvider() { if (parsed.literal && parsed.constant) { return parsed; } else { - return function sceParseAsTrusted(self, locals) { - return sce.getTrusted(type, parsed(self, locals)); - }; + return $parse(expr, function (value) { + return sce.getTrusted(type, value); + }); } }; @@ -14169,9 +14889,176 @@ function $SnifferProvider() { }]; } +var $compileMinErr = minErr('$compile'); + +/** + * @ngdoc service + * @name $templateRequest + * + * @description + * The `$templateRequest` service downloads the provided template using `$http` and, upon success, + * stores the contents inside of `$templateCache`. If the HTTP request fails or the response data + * of the HTTP request is empty then a `$compile` error will be thrown (the exception can be thwarted + * by setting the 2nd parameter of the function to true). + * + * @param {string} tpl The HTTP request template URL + * @param {boolean=} ignoreRequestError Whether or not to ignore the exception when the request fails or the template is empty + * + * @return {Promise} the HTTP Promise for the given. + * + * @property {number} totalPendingRequests total amount of pending template requests being downloaded. + */ +function $TemplateRequestProvider() { + this.$get = ['$templateCache', '$http', '$q', function($templateCache, $http, $q) { + function handleRequestFn(tpl, ignoreRequestError) { + var self = handleRequestFn; + self.totalPendingRequests++; + + return $http.get(tpl, { cache : $templateCache }) + .then(function(response) { + var html = response.data; + if(!html || html.length === 0) { + return handleError(); + } + + self.totalPendingRequests--; + $templateCache.put(tpl, html); + return html; + }, handleError); + + function handleError() { + self.totalPendingRequests--; + if (!ignoreRequestError) { + throw $compileMinErr('tpload', 'Failed to load template: {0}', tpl); + } + return $q.reject(); + } + } + + handleRequestFn.totalPendingRequests = 0; + + return handleRequestFn; + }]; +} + +function $$TestabilityProvider() { + this.$get = ['$rootScope', '$browser', '$location', + function($rootScope, $browser, $location) { + + /** + * @name $testability + * + * @description + * The private $$testability service provides a collection of methods for use when debugging + * or by automated test and debugging tools. + */ + var testability = {}; + + /** + * @name $$testability#findBindings + * + * @description + * Returns an array of elements that are bound (via ng-bind or {{}}) + * to expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The binding expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. Filters and whitespace are ignored. + */ + testability.findBindings = function(element, expression, opt_exactMatch) { + var bindings = element.getElementsByClassName('ng-binding'); + var matches = []; + forEach(bindings, function(binding) { + var dataBinding = angular.element(binding).data('$binding'); + if (dataBinding) { + forEach(dataBinding, function(bindingName) { + if (opt_exactMatch) { + var matcher = new RegExp('(^|\\s)' + expression + '(\\s|\\||$)'); + if (matcher.test(bindingName)) { + matches.push(binding); + } + } else { + if (bindingName.indexOf(expression) != -1) { + matches.push(binding); + } + } + }); + } + }); + return matches; + }; + + /** + * @name $$testability#findModels + * + * @description + * Returns an array of elements that are two-way found via ng-model to + * expressions matching the input. + * + * @param {Element} element The element root to search from. + * @param {string} expression The model expression to match. + * @param {boolean} opt_exactMatch If true, only returns exact matches + * for the expression. + */ + testability.findModels = function(element, expression, opt_exactMatch) { + var prefixes = ['ng-', 'data-ng-', 'ng\\:']; + for (var p = 0; p < prefixes.length; ++p) { + var attributeEquals = opt_exactMatch ? '=' : '*='; + var selector = '[' + prefixes[p] + 'model' + attributeEquals + '"' + expression + '"]'; + var elements = element.querySelectorAll(selector); + if (elements.length) { + return elements; + } + } + }; + + /** + * @name $$testability#getLocation + * + * @description + * Shortcut for getting the location in a browser agnostic way. Returns + * the path, search, and hash. (e.g. /path?a=b#hash) + */ + testability.getLocation = function() { + return $location.url(); + }; + + /** + * @name $$testability#setLocation + * + * @description + * Shortcut for navigating to a location without doing a full page reload. + * + * @param {string} url The location url (path, search and hash, + * e.g. /path?a=b#hash) to go to. + */ + testability.setLocation = function(url) { + if (url !== $location.url()) { + $location.url(url); + $rootScope.$digest(); + } + }; + + /** + * @name $$testability#whenStable + * + * @description + * Calls the callback when $timeout and $http requests are completed. + * + * @param {function} callback + */ + testability.whenStable = function(callback) { + $browser.notifyWhenNoOutstandingRequests(callback); + }; + + return testability; + }]; +} + function $TimeoutProvider() { - this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', - function($rootScope, $browser, $q, $exceptionHandler) { + this.$get = ['$rootScope', '$browser', '$q', '$$q', '$exceptionHandler', + function($rootScope, $browser, $q, $$q, $exceptionHandler) { var deferreds = {}; @@ -14201,9 +15088,9 @@ function $TimeoutProvider() { * */ function timeout(fn, delay, invokeApply) { - var deferred = $q.defer(), + var skipApply = (isDefined(invokeApply) && !invokeApply), + deferred = (skipApply ? $$q : $q).defer(), promise = deferred.promise, - skipApply = (isDefined(invokeApply) && !invokeApply), timeoutId; timeoutId = $browser.defer(function() { @@ -14400,6 +15287,17 @@ function $WindowProvider(){ this.$get = valueFn(window); } +/* global currencyFilter: true, + dateFilter: true, + filterFilter: true, + jsonFilter: true, + limitToFilter: true, + lowercaseFilter: true, + numberFilter: true, + orderByFilter: true, + uppercaseFilter: true, + */ + /** * @ngdoc provider * @name $filterProvider @@ -14449,16 +15347,6 @@ function $WindowProvider(){ * For more information about how angular filters work, and how to create your own filters, see * {@link guide/filter Filters} in the Angular Developer Guide. */ -/** - * @ngdoc method - * @name $filterProvider#register - * @description - * Register filter factory function. - * - * @param {String} name Name of the filter. - * @param {Function} fn The filter factory function which is injectable. - */ - /** * @ngdoc service @@ -14497,7 +15385,7 @@ function $FilterProvider($provide) { /** * @ngdoc method - * @name $controllerProvider#register + * @name $filterProvider#register * @param {string|Object} name Name of the filter function, or an object map of filters where * the keys are the filter names and the values are the filter factories. * @returns {Object} Registered filter instance, or if a map of filters was provided then a map @@ -14570,11 +15458,13 @@ function $FilterProvider($provide) { * which have property `name` containing "M" and property `phone` containing "1". A special * property name `$` can be used (as in `{$:"text"}`) to accept a match against any * property of the object. That's equivalent to the simple substring match with a `string` - * as described above. + * as described above. The predicate can be negated by prefixing the string with `!`. + * For Example `{name: "!M"}` predicate will return an array of items which have property `name` + * not containing "M". * - * - `function(value)`: A predicate function can be used to write arbitrary filters. The function is - * called for each element of `array`. The final result is an array of those elements that - * the predicate returned true for. + * - `function(value, index)`: A predicate function can be used to write arbitrary filters. The + * function is called for each element of `array`. The final result is an array of those + * elements that the predicate returned true for. * * @param {function(actual, expected)|true|undefined} comparator Comparator which is used in * determining if the expected value (from the filter expression) and actual value (from @@ -14667,9 +15557,9 @@ function filterFilter() { var comparatorType = typeof(comparator), predicates = []; - predicates.check = function(value) { + predicates.check = function(value, index) { for (var j = 0; j < predicates.length; j++) { - if(!predicates[j](value)) { + if(!predicates[j](value, index)) { return false; } } @@ -14758,7 +15648,7 @@ function filterFilter() { var filtered = []; for ( var j = 0; j < array.length; j++) { var value = array[j]; - if (predicates.check(value)) { + if (predicates.check(value, j)) { filtered.push(value); } } @@ -14819,8 +15709,12 @@ function currencyFilter($locale) { var formats = $locale.NUMBER_FORMATS; return function(amount, currencySymbol){ if (isUndefined(currencySymbol)) currencySymbol = formats.CURRENCY_SYM; - return formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). - replace(/\u00A4/g, currencySymbol); + + // if null or undefined pass it through + return (amount == null) + ? amount + : formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). + replace(/\u00A4/g, currencySymbol); }; } @@ -14879,14 +15773,18 @@ numberFilter.$inject = ['$locale']; function numberFilter($locale) { var formats = $locale.NUMBER_FORMATS; return function(number, fractionSize) { - return formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, - fractionSize); + + // if null or undefined pass it through + return (number == null) + ? number + : formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, + fractionSize); }; } var DECIMAL_SEP = '.'; function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { - if (number == null || !isFinite(number) || isObject(number)) return ''; + if (!isFinite(number) || isObject(number)) return ''; var isNegative = number < 0; number = Math.abs(number); @@ -14919,6 +15817,10 @@ function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round number = +(Math.round(+(number.toString() + 'e' + fractionSize)).toString() + 'e' + -fractionSize); + if (number === 0) { + isNegative = false; + } + var fraction = ('' + number).split(DECIMAL_SEP); var whole = fraction[0]; fraction = fraction[1] || ''; @@ -15007,6 +15909,32 @@ function timeZoneGetter(date) { return paddedZone; } +function getFirstThursdayOfYear(year) { + // 0 = index of January + var dayOfWeekOnFirst = (new Date(year, 0, 1)).getDay(); + // 4 = index of Thursday (+1 to account for 1st = 5) + // 11 = index of *next* Thursday (+1 account for 1st = 12) + return new Date(year, 0, ((dayOfWeekOnFirst <= 4) ? 5 : 12) - dayOfWeekOnFirst); +} + +function getThursdayThisWeek(datetime) { + return new Date(datetime.getFullYear(), datetime.getMonth(), + // 4 = index of Thursday + datetime.getDate() + (4 - datetime.getDay())); +} + +function weekGetter(size) { + return function(date) { + var firstThurs = getFirstThursdayOfYear(date.getFullYear()), + thisThurs = getThursdayThisWeek(date); + + var diff = +thisThurs - +firstThurs, + result = 1 + Math.round(diff / 6.048e8); // 6.048e8 ms per week + + return padNumber(result, size); + }; +} + function ampmGetter(date, formats) { return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; } @@ -15035,10 +15963,12 @@ var DATE_FORMATS = { EEEE: dateStrGetter('Day'), EEE: dateStrGetter('Day', true), a: ampmGetter, - Z: timeZoneGetter + Z: timeZoneGetter, + ww: weekGetter(2), + w: weekGetter(1) }; -var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/, +var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZEw']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z|w+))(.*)/, NUMBER_STRING = /^\-?\d+$/; /** @@ -15064,40 +15994,44 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ * * `'EEE'`: Day in Week, (Sun-Sat) * * `'HH'`: Hour in day, padded (00-23) * * `'H'`: Hour in day (0-23) - * * `'hh'`: Hour in am/pm, padded (01-12) - * * `'h'`: Hour in am/pm, (1-12) + * * `'hh'`: Hour in AM/PM, padded (01-12) + * * `'h'`: Hour in AM/PM, (1-12) * * `'mm'`: Minute in hour, padded (00-59) * * `'m'`: Minute in hour (0-59) * * `'ss'`: Second in minute, padded (00-59) * * `'s'`: Second in minute (0-59) * * `'.sss' or ',sss'`: Millisecond in second, padded (000-999) - * * `'a'`: am/pm marker + * * `'a'`: AM/PM marker * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-+1200) + * * `'ww'`: ISO-8601 week of year (00-53) + * * `'w'`: ISO-8601 week of year (0-53) * * `format` string can also be one of the following predefined * {@link guide/i18n localizable formats}: * * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale - * (e.g. Sep 3, 2010 12:05:08 pm) - * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 pm) - * * `'fullDate'`: equivalent to `'EEEE, MMMM d,y'` for en_US locale + * (e.g. Sep 3, 2010 12:05:08 PM) + * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 PM) + * * `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` for en_US locale * (e.g. Friday, September 3, 2010) * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010) * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) - * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 pm) - * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 pm) + * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 PM) + * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 PM) * - * `format` string can contain literal values. These need to be quoted with single quotes (e.g. - * `"h 'in the morning'"`). In order to output single quote, use two single quotes in a sequence + * `format` string can contain literal values. These need to be escaped by surrounding with single quotes (e.g. + * `"h 'in the morning'"`). In order to output a single quote, escape it - i.e., two single quotes in a sequence * (e.g. `"h 'o''clock'"`). * * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or - * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.SSSZ and its + * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.sssZ and its * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is * specified in the string input, the time is considered to be in the local timezone. * @param {string=} format Formatting rules (see Description). If not specified, * `mediumDate` is used. + * @param {string=} timezone Timezone to be used for formatting. Right now, only `'UTC'` is supported. + * If not specified, the timezone of the browser will be used. * @returns {string} Formatted string or the input if input is not recognized as date/millis. * * @example @@ -15109,6 +16043,8 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}
    {{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}: {{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}}
    + {{1288323623006 | date:"MM/dd/yyyy 'at' h:mma"}}: + {{'1288323623006' | date:"MM/dd/yyyy 'at' h:mma"}}
    it('should format date', function() { @@ -15118,6 +16054,8 @@ var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+ toMatch(/2010\-10\-2\d \d{2}:\d{2}:\d{2} (\-|\+)?\d{4}/); expect(element(by.binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")).getText()). toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); + expect(element(by.binding("'1288323623006' | date:\"MM/dd/yyyy 'at' h:mma\"")).getText()). + toMatch(/10\/2\d\/2010 at \d{1,2}:\d{2}(AM|PM)/); }); @@ -15153,7 +16091,7 @@ function dateFilter($locale) { } - return function(date, format) { + return function(date, format, timezone) { var text = '', parts = [], fn, match; @@ -15161,11 +16099,7 @@ function dateFilter($locale) { format = format || 'mediumDate'; format = $locale.DATETIME_FORMATS[format] || format; if (isString(date)) { - if (NUMBER_STRING.test(date)) { - date = int(date); - } else { - date = jsonStringToDate(date); - } + date = NUMBER_STRING.test(date) ? int(date) : jsonStringToDate(date); } if (isNumber(date)) { @@ -15187,6 +16121,10 @@ function dateFilter($locale) { } } + if (timezone && timezone === 'UTC') { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + } forEach(parts, function(value){ fn = DATE_FORMATS[value]; text += fn ? fn(date, $locale.DATETIME_FORMATS) @@ -15386,9 +16324,13 @@ function limitToFilter(){ * * - `function`: Getter function. The result of this function will be sorted using the * `<`, `=`, `>` operator. - * - `string`: An Angular expression which evaluates to an object to order by, such as 'name' - * to sort by a property called 'name'. Optionally prefixed with `+` or `-` to control - * ascending or descending sort order (for example, +name or -name). + * - `string`: An Angular expression. The result of this expression is used to compare elements + * (for example `name` to sort by a property called `name` or `name.substr(0, 3)` to sort by + * 3 first characters of a property called `name`). The result of a constant expression + * is interpreted as a property name to be used in comparisons (for example `"special name"` + * to sort object by the value of their `special name` property). An expression can be + * optionally prefixed with `+` or `-` to control ascending or descending sort order + * (for example, `+name` or `-name`). * - `Array`: An array of function or string predicates. The first predicate in the array * is used for sorting, but when two items are equivalent, the next predicate is used. * @@ -15440,7 +16382,7 @@ function limitToFilter(){ * @example -
    +
    Name @@ -15479,7 +16421,7 @@ function limitToFilter(){ orderByFilter.$inject = ['$parse']; function orderByFilter($parse){ return function(array, sortPredicate, reverseOrder) { - if (!isArray(array)) return array; + if (!(isArrayLike(array))) return array; if (!sortPredicate) return array; sortPredicate = isArray(sortPredicate) ? sortPredicate: [sortPredicate]; sortPredicate = map(sortPredicate, function(predicate){ @@ -15513,7 +16455,7 @@ function orderByFilter($parse){ return 0; } function reverseComparator(comp, descending) { - return toBoolean(descending) + return descending ? function(a,b){return comp(b,a);} : comp; } @@ -15521,6 +16463,10 @@ function orderByFilter($parse){ var t1 = typeof v1; var t2 = typeof v2; if (t1 == t2) { + if (isDate(v1) && isDate(v2)) { + v1 = v1.valueOf(); + v2 = v2.valueOf(); + } if (t1 == "string") { v1 = v1.toLowerCase(); v2 = v2.toLowerCase(); @@ -15609,12 +16555,12 @@ var htmlAnchorDirective = valueFn({ * * The wrong way to write it: * ```html - * + * link1 * ``` * * The correct way to write it: * ```html - * + * link1 * ``` * * @element A @@ -15752,7 +16698,7 @@ var htmlAnchorDirective = valueFn({ * * @description * - * The following markup will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: + * We shouldn't do this, because it will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: * ```html *
    * @@ -15943,6 +16889,7 @@ forEach(BOOLEAN_ATTR, function(propName, attrName) { var normalized = directiveNormalize('ng-' + attrName); ngAttributeAliasDirectives[normalized] = function() { return { + restrict: 'A', priority: 100, link: function(scope, element, attr) { scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { @@ -15953,6 +16900,29 @@ forEach(BOOLEAN_ATTR, function(propName, attrName) { }; }); +// aliased input attrs are evaluated +forEach(ALIASED_ATTR, function(htmlAttr, ngAttr) { + ngAttributeAliasDirectives[ngAttr] = function() { + return { + priority: 100, + link: function(scope, element, attr) { + //special case ngPattern when a literal regular expression value + //is used as the expression (this way we don't have to watch anything). + if (ngAttr === "ngPattern" && attr.ngPattern.charAt(0) == "/") { + var match = attr.ngPattern.match(REGEX_STRING_REGEXP); + if (match) { + attr.$set("ngPattern", new RegExp(match[1], match[2])); + return; + } + } + + scope.$watch(attr[ngAttr], function ngAttrAliasWatchAction(value) { + attr.$set(ngAttr, value); + }); + } + }; + }; +}); // ng-src, ng-srcset, ng-href are interpolated forEach(['src', 'srcset', 'href'], function(attrName) { @@ -15972,8 +16942,12 @@ forEach(['src', 'srcset', 'href'], function(attrName) { } attr.$observe(normalized, function(value) { - if (!value) - return; + if (!value) { + if (attrName === 'href') { + attr.$set(name, null); + } + return; + } attr.$set(name, value); @@ -15988,14 +16962,19 @@ forEach(['src', 'srcset', 'href'], function(attrName) { }; }); -/* global -nullFormCtrl */ +/* global -nullFormCtrl, -SUBMITTED_CLASS, addSetValidityMethod: true + */ var nullFormCtrl = { $addControl: noop, $removeControl: noop, $setValidity: noop, + $$setPending: noop, $setDirty: noop, - $setPristine: noop -}; + $setPristine: noop, + $setSubmitted: noop, + $$clearControlValidity: noop +}, +SUBMITTED_CLASS = 'ng-submitted'; /** * @ngdoc type @@ -16005,13 +16984,13 @@ var nullFormCtrl = { * @property {boolean} $dirty True if user has already interacted with the form. * @property {boolean} $valid True if all of the containing forms and controls are valid. * @property {boolean} $invalid True if at least one containing control or form is invalid. + * @property {boolean} $submitted True if user has submitted the form even if its invalid. * - * @property {Object} $error Is an object hash, containing references to all invalid controls or - * forms, where: + * @property {Object} $error Is an object hash, containing references to controls or + * forms with failing validators, where: * * - keys are validation tokens (error names), - * - values are arrays of controls or forms that are invalid for given error name. - * + * - values are arrays of controls or forms that have a failing validator for given error name. * * Built-in validation tokens: * @@ -16038,29 +17017,57 @@ FormController.$inject = ['$element', '$attrs', '$scope', '$animate']; function FormController(element, attrs, $scope, $animate) { var form = this, parentForm = element.parent().controller('form') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}, controls = []; // init state + form.$error = {}; + form.$$success = {}; + form.$pending = undefined; form.$name = attrs.name || attrs.ngForm; form.$dirty = false; form.$pristine = true; form.$valid = true; form.$invalid = false; + form.$submitted = false; parentForm.$addControl(form); // Setup initial state of the control element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass(element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } + /** + * @ngdoc method + * @name form.FormController#$rollbackViewValue + * + * @description + * Rollback all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is typically needed by the reset button of + * a form that uses `ng-model-options` to pend updates. + */ + form.$rollbackViewValue = function() { + forEach(controls, function(control) { + control.$rollbackViewValue(); + }); + }; + + /** + * @ngdoc method + * @name form.FormController#$commitViewValue + * + * @description + * Commit all form controls pending updates to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. This method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + form.$commitViewValue = function() { + forEach(controls, function(control) { + control.$commitViewValue(); + }); + }; /** * @ngdoc method @@ -16095,13 +17102,17 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } - forEach(errors, function(queue, validationToken) { - form.$setValidity(validationToken, true, control); + forEach(form.$pending, function(value, name) { + form.$setValidity(name, null, control); + }); + forEach(form.$error, function(value, name) { + form.$setValidity(name, null, control); }); arrayRemove(controls, control); }; + /** * @ngdoc method * @name form.FormController#$setValidity @@ -16111,43 +17122,33 @@ function FormController(element, attrs, $scope, $animate) { * * This method will also propagate to parent forms. */ - form.$setValidity = function(validationToken, isValid, control) { - var queue = errors[validationToken]; - - if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; - if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; - } - errors[validationToken] = false; - toggleValidCss(true, validationToken); - parentForm.$setValidity(validationToken, true, form); + addSetValidityMethod({ + ctrl: this, + $element: element, + set: function(object, property, control) { + var list = object[property]; + if (!list) { + object[property] = [control]; + } else { + var index = list.indexOf(control); + if (index === -1) { + list.push(control); } } - - } else { - if (!invalidCount) { - toggleValidCss(isValid); + }, + unset: function(object, property, control) { + var list = object[property]; + if (!list) { + return; } - if (queue) { - if (includes(queue, control)) return; - } else { - errors[validationToken] = queue = []; - invalidCount++; - toggleValidCss(false, validationToken); - parentForm.$setValidity(validationToken, false, form); + arrayRemove(list, control); + if (list.length === 0) { + delete object[property]; } - queue.push(control); - - form.$valid = false; - form.$invalid = true; - } - }; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method @@ -16182,16 +17183,28 @@ function FormController(element, attrs, $scope, $animate) { * saving or resetting it. */ form.$setPristine = function () { - $animate.removeClass(element, DIRTY_CLASS); - $animate.addClass(element, PRISTINE_CLASS); + $animate.setClass(element, PRISTINE_CLASS, DIRTY_CLASS + ' ' + SUBMITTED_CLASS); form.$dirty = false; form.$pristine = true; + form.$submitted = false; forEach(controls, function(control) { control.$setPristine(); }); }; -} + /** + * @ngdoc method + * @name form.FormController#$setSubmitted + * + * @description + * Sets the form to its submitted state. + */ + form.$setSubmitted = function () { + $animate.addClass(element, SUBMITTED_CLASS); + form.$submitted = true; + parentForm.$setSubmitted(); + }; +} /** * @ngdoc directive @@ -16241,6 +17254,7 @@ function FormController(element, attrs, $scope, $animate) { * - `ng-invalid` is set if the form is invalid. * - `ng-pristine` is set if the form is pristine. * - `ng-dirty` is set if the form is dirty. + * - `ng-submitted` is set if the form was submitted. * * Keep in mind that ngAnimate can detect each of these classes when added and removed. * @@ -16274,8 +17288,9 @@ function FormController(element, attrs, $scope, $animate) { * hitting enter in any of the input fields will trigger the click handler on the *first* button or * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) * - * @param {string=} name Name of the form. If specified, the form controller will be published into - * related scope, under this name. + * Any pending `ngModelOptions` changes will take place immediately when an enclosing form is + * submitted. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. * * ## Animation Hooks * @@ -16353,6 +17368,8 @@ function FormController(element, attrs, $scope, $animate) { * + * @param {string=} name Name of the form. If specified, the form controller will be published into + * related scope, under this name. */ var formDirectiveFactory = function(isNgForm) { return ['$timeout', function($timeout) { @@ -16370,19 +17387,24 @@ var formDirectiveFactory = function(isNgForm) { // IE 9 is not affected because it doesn't fire a submit event and try to do a full // page reload if the form was destroyed by submission of the form via a click handler // on a button in the form. Looks like an IE9 specific bug. - var preventDefaultListener = function(event) { + var handleFormSubmission = function(event) { + scope.$apply(function() { + controller.$commitViewValue(); + controller.$setSubmitted(); + }); + event.preventDefault ? event.preventDefault() : event.returnValue = false; // IE }; - addEventListenerFn(formElement[0], 'submit', preventDefaultListener); + addEventListenerFn(formElement[0], 'submit', handleFormSubmission); // unregister the preventDefault listener so that we don't not leak memory but in a // way that will achieve the prevention of the default action. formElement.on('$destroy', function() { $timeout(function() { - removeEventListenerFn(formElement[0], 'submit', preventDefaultListener); + removeEventListenerFn(formElement[0], 'submit', handleFormSubmission); }, 0, false); }); } @@ -16414,17 +17436,27 @@ var formDirectiveFactory = function(isNgForm) { var formDirective = formDirectiveFactory(); var ngFormDirective = formDirectiveFactory(true); -/* global - - -VALID_CLASS, - -INVALID_CLASS, - -PRISTINE_CLASS, - -DIRTY_CLASS +/* global VALID_CLASS: true, + INVALID_CLASS: true, + PRISTINE_CLASS: true, + DIRTY_CLASS: true, + UNTOUCHED_CLASS: true, + TOUCHED_CLASS: true, */ +// Regex code is obtained from SO: https://stackoverflow.com/questions/3143070/javascript-regex-iso-datetime#answer-3143231 +var ISO_DATE_REGEXP = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/; var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; +var DATE_REGEXP = /^(\d{4})-(\d{2})-(\d{2})$/; +var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?$/; +var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; +var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; +var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; +var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; + +var $ngModelMinErr = new minErr('ngModel'); var inputType = { @@ -16433,7 +17465,9 @@ var inputType = { * @name input[text] * * @description - * Standard HTML text input with angular data binding. + * Standard HTML text input with angular data binding, inherited by most of the `input` elements. + * + * *NOTE* Not every feature offered is available for all input types. * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. @@ -16451,6 +17485,8 @@ var inputType = { * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. * * @example @@ -16506,6 +17542,447 @@ var inputType = { */ 'text': textInputType, + /** + * @ngdoc input + * @name input[date] + * + * @description + * Input with date validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, text must be entered in a valid ISO-8601 + * date format (yyyy-MM-dd), for example: `2009-01-06`. Since many + * modern browsers do not yet support this input type, it is important to provide cues to users on the + * expected input format via a placeholder or label. The model must always be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO date string (yyyy-MM-dd). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO date string (yyyy-MM-dd). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
    + Pick a date in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-MM-dd"}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.required = {{!!myForm.$error.required}}
    +
    +
    + + var value = element(by.binding('value | date: "yyyy-MM-dd"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (see https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10-22'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
    + */ + 'date': createDateInputType('date', DATE_REGEXP, + createDateParser(DATE_REGEXP, ['yyyy', 'MM', 'dd']), + 'yyyy-MM-dd'), + + /** + * @ngdoc input + * @name input[dateTimeLocal] + * + * @description + * Input with datetime validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local datetime format (yyyy-MM-ddTHH:mm:ss), for example: `2010-12-28T14:57:00`. The model must be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO datetime format (yyyy-MM-ddTHH:mm:ss). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
    + Pick a date between in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-MM-ddTHH:mm:ss"}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.required = {{!!myForm.$error.required}}
    +
    +
    + + var value = element(by.binding('value | date: "yyyy-MM-ddTHH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2010-12-28T14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01-01T23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
    + */ + 'datetime-local': createDateInputType('datetimelocal', DATETIMELOCAL_REGEXP, + createDateParser(DATETIMELOCAL_REGEXP, ['yyyy', 'MM', 'dd', 'HH', 'mm', 'ss']), + 'yyyy-MM-ddTHH:mm:ss'), + + /** + * @ngdoc input + * @name input[time] + * + * @description + * Input with time validation and transformation. In browsers that do not yet support + * the HTML5 date input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * local time format (HH:mm:ss), for example: `14:57:00`. Model must be a Date object. This binding will always output a + * Date object to the model of January 1, 1970, or local date `new Date(1970, 0, 1, HH, mm, ss)`. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO time format (HH:mm:ss). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be a + * valid ISO time format (HH:mm:ss). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
    + Pick a between 8am and 5pm: + + + Required! + + Not a valid date! + value = {{value | date: "HH:mm:ss"}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.required = {{!!myForm.$error.required}}
    +
    +
    + + var value = element(by.binding('value | date: "HH:mm:ss"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('14:57:00'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('23:59:00'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
    + */ + 'time': createDateInputType('time', TIME_REGEXP, + createDateParser(TIME_REGEXP, ['HH', 'mm', 'ss']), + 'HH:mm:ss'), + + /** + * @ngdoc input + * @name input[week] + * + * @description + * Input with week-of-the-year validation and transformation to Date. In browsers that do not yet support + * the HTML5 week input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * week format (yyyy-W##), for example: `2013-W02`. The model must always be a Date object. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be a + * valid ISO week format (yyyy-W##). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must be + * a valid ISO week format (yyyy-W##). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
    + Pick a date between in 2013: + + + Required! + + Not a valid date! + value = {{value | date: "yyyy-Www"}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.required = {{!!myForm.$error.required}}
    +
    +
    + + var value = element(by.binding('value | date: "yyyy-Www"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-W01'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-W01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
    + */ + 'week': createDateInputType('week', WEEK_REGEXP, weekParser, 'yyyy-Www'), + + /** + * @ngdoc input + * @name input[month] + * + * @description + * Input with month validation and transformation. In browsers that do not yet support + * the HTML5 month input, a text element will be used. In that case, the text must be entered in a valid ISO-8601 + * month format (yyyy-MM), for example: `2009-01`. The model must always be a Date object. In the event the model is + * not set to the first of the month, the first of that model's month is assumed. + * + * The timezone to be used to read/write the `Date` instance in the model can be defined using + * {@link ng.directive:ngModelOptions ngModelOptions}. By default, this is the timezone of the browser. + * + * @param {string} ngModel Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the control is published. + * @param {string=} min Sets the `min` validation error key if the value entered is less than `min`. This must be + * a valid ISO month format (yyyy-MM). + * @param {string=} max Sets the `max` validation error key if the value entered is greater than `max`. This must + * be a valid ISO month format (yyyy-MM). + * @param {string=} required Sets `required` validation error key if the value is not entered. + * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to + * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of + * `required` when you want to data-bind to the `required` attribute. + * @param {string=} ngChange Angular expression to be executed when input changes due to user + * interaction with the input element. + * + * @example + + + +
    + Pick a month int 2013: + + + Required! + + Not a valid month! + value = {{value | date: "yyyy-MM"}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.required = {{!!myForm.$error.required}}
    +
    +
    + + var value = element(by.binding('value | date: "yyyy-MM"')); + var valid = element(by.binding('myForm.input.$valid')); + var input = element(by.model('value')); + + // currently protractor/webdriver does not support + // sending keys to all known HTML5 input controls + // for various browsers (https://github.com/angular/protractor/issues/562). + function setInput(val) { + // set the value of the element and force validation. + var scr = "var ipt = document.getElementById('exampleInput'); " + + "ipt.value = '" + val + "';" + + "angular.element(ipt).scope().$apply(function(s) { s.myForm[ipt.name].$setViewValue('" + val + "'); });"; + browser.executeScript(scr); + } + + it('should initialize to model', function() { + expect(value.getText()).toContain('2013-10'); + expect(valid.getText()).toContain('myForm.input.$valid = true'); + }); + + it('should be invalid if empty', function() { + setInput(''); + expect(value.getText()).toEqual('value ='); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + + it('should be invalid if over max', function() { + setInput('2015-01'); + expect(value.getText()).toContain(''); + expect(valid.getText()).toContain('myForm.input.$valid = false'); + }); + +
    + */ + 'month': createDateInputType('month', MONTH_REGEXP, + createDateParser(MONTH_REGEXP, ['yyyy', 'MM']), + 'yyyy-MM'), /** * @ngdoc input @@ -16799,8 +18276,8 @@ var inputType = { * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngTrueValue The value to which the expression should be set when selected. - * @param {string=} ngFalseValue The value to which the expression should be set when not selected. + * @param {expression=} ngTrueValue The value to which the expression should be set when selected. + * @param {expression=} ngFalseValue The value to which the expression should be set when not selected. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @@ -16817,7 +18294,7 @@ var inputType = {
    Value1:
    Value2:
    + ng-true-value="'YES'" ng-false-value="'NO'">
    value1 = {{value1}}
    value2 = {{value2}}
    @@ -16848,13 +18325,6 @@ var inputType = { 'file': noop }; -// A helper function to call $setValidity and return the value / undefined, -// a pattern that is repeated a lot in the input validation logic. -function validate(ctrl, validatorName, validity, value){ - ctrl.$setValidity(validatorName, validity); - return validity ? value : undefined; -} - function testFlags(validity, flags) { var i, flag; if (flags) { @@ -16868,29 +18338,21 @@ function testFlags(validity, flags) { return false; } -// Pass validity so that behaviour can be mocked easier. -function addNativeHtml5Validators(ctrl, validatorName, badFlags, ignoreFlags, validity) { - if (isObject(validity)) { - ctrl.$$hasNativeValidators = true; - var validator = function(value) { - // Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can - // perform the required validation) - if (!ctrl.$error[validatorName] && - !testFlags(validity, ignoreFlags) && - testFlags(validity, badFlags)) { - ctrl.$setValidity(validatorName, false); - return; - } - return value; - }; - ctrl.$parsers.push(validator); - } +function stringBasedInputType(ctrl) { + ctrl.$formatters.push(function(value) { + return ctrl.$isEmpty(value) ? value : value.toString(); + }); } function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); +} + +function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { var validity = element.prop(VALIDITY_STATE_PROPERTY); var placeholder = element[0].placeholder, noevent = {}; - ctrl.$$validityState = validity; + var type = lowercase(element[0].type); // In composition mode, users are still inputing intermediate text buffer, // hold the listener until composition is done. @@ -16910,7 +18372,8 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { var listener = function(ev) { if (composing) return; - var value = element.val(); + var value = element.val(), + event = ev && ev.type; // IE (11 and under) seem to emit an 'input' event if the placeholder value changes. // We don't want to dirty the value when this happens, so we abort here. Unfortunately, @@ -16923,23 +18386,16 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { // By default we will trim the value // If the attribute ng-trim exists we will avoid trimming - // e.g. - if (toBoolean(attr.ngTrim || 'T')) { + // If input type is 'password', the value is never trimmed + if (type !== 'password' && (!attr.ngTrim || attr.ngTrim !== 'false')) { value = trim(value); } - // If a control is suffering from bad input, browsers discard its value, so it may be - // necessary to revalidate even if the control's value is the same empty value twice in - // a row. - var revalidate = validity && ctrl.$$hasNativeValidators; - if (ctrl.$viewValue !== value || (value === '' && revalidate)) { - if (scope.$$phase) { - ctrl.$setViewValue(value); - } else { - scope.$apply(function() { - ctrl.$setViewValue(value); - }); - } + // If a control is suffering from bad input (due to native validators), browsers discard its + // value, so it may be necessary to revalidate (by calling $setViewValue again) even if the + // control's value is the same empty value twice in a row. + if (ctrl.$viewValue !== value || (value === '' && ctrl.$$hasNativeValidators)) { + ctrl.$setViewValue(value, event); } }; @@ -16950,10 +18406,10 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { } else { var timeout; - var deferListener = function() { + var deferListener = function(ev) { if (!timeout) { timeout = $browser.defer(function() { - listener(); + listener(ev); timeout = null; }); } @@ -16966,7 +18422,7 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { // command modifiers arrows if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - deferListener(); + deferListener(event); }); // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it @@ -16982,129 +18438,213 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$render = function() { element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); }; +} - // pattern validator - var pattern = attr.ngPattern, - patternValidator, - match; +function weekParser(isoWeek) { + if (isDate(isoWeek)) { + return isoWeek; + } - if (pattern) { - var validateRegex = function(regexp, value) { - return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || regexp.test(value), value); - }; - match = pattern.match(/^\/(.*)\/([gim]*)$/); - if (match) { - pattern = new RegExp(match[1], match[2]); - patternValidator = function(value) { - return validateRegex(pattern, value); - }; - } else { - patternValidator = function(value) { - var patternObj = scope.$eval(pattern); + if (isString(isoWeek)) { + WEEK_REGEXP.lastIndex = 0; + var parts = WEEK_REGEXP.exec(isoWeek); + if (parts) { + var year = +parts[1], + week = +parts[2], + firstThurs = getFirstThursdayOfYear(year), + addDays = (week - 1) * 7; + return new Date(year, 0, firstThurs.getDate() + addDays); + } + } - if (!patternObj || !patternObj.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', pattern, - patternObj, startingTag(element)); - } - return validateRegex(patternObj, value); - }; + return NaN; +} + +function createDateParser(regexp, mapping) { + return function(iso) { + var parts, map; + + if (isDate(iso)) { + return iso; } - ctrl.$formatters.push(patternValidator); - ctrl.$parsers.push(patternValidator); - } + if (isString(iso)) { + // When a date is JSON'ified to wraps itself inside of an extra + // set of double quotes. This makes the date parsing code unable + // to match the date string and parse it as a date. + if (iso.charAt(0) == '"' && iso.charAt(iso.length-1) == '"') { + iso = iso.substring(1, iso.length-1); + } + if (ISO_DATE_REGEXP.test(iso)) { + return new Date(iso); + } + regexp.lastIndex = 0; + parts = regexp.exec(iso); - // min length validator - if (attr.ngMinlength) { - var minlength = int(attr.ngMinlength); - var minLengthValidator = function(value) { - return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value); - }; + if (parts) { + parts.shift(); + map = { yyyy: 1970, MM: 1, dd: 1, HH: 0, mm: 0, ss: 0 }; - ctrl.$parsers.push(minLengthValidator); - ctrl.$formatters.push(minLengthValidator); - } + forEach(parts, function(part, index) { + if (index < mapping.length) { + map[mapping[index]] = +part; + } + }); + return new Date(map.yyyy, map.MM - 1, map.dd, map.HH, map.mm, map.ss || 0); + } + } - // max length validator - if (attr.ngMaxlength) { - var maxlength = int(attr.ngMaxlength); - var maxLengthValidator = function(value) { - return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value); - }; + return NaN; + }; +} - ctrl.$parsers.push(maxLengthValidator); - ctrl.$formatters.push(maxLengthValidator); +function createDateInputType(type, regexp, parseDate, format) { + return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + badInputChecker(scope, element, attr, ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + + ctrl.$$parserName = type; + ctrl.$parsers.push(function(value) { + if (ctrl.$isEmpty(value)) return null; + if (regexp.test(value)) { + var parsedDate = parseDate(value); + if (timezone === 'UTC') { + parsedDate.setMinutes(parsedDate.getMinutes() - parsedDate.getTimezoneOffset()); + } + return parsedDate; + } + return undefined; + }); + + ctrl.$formatters.push(function(value) { + if (isDate(value)) { + return $filter('date')(value, format, timezone); + } + return ''; + }); + + if (isDefined(attr.min) || attr.ngMin) { + var minVal; + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(minVal) || parseDate(value) >= minVal; + }; + attr.$observe('min', function(val) { + minVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } + + if (isDefined(attr.max) || attr.ngMax) { + var maxVal; + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(maxVal) || parseDate(value) <= maxVal; + }; + attr.$observe('max', function(val) { + maxVal = parseObservedDateValue(val); + ctrl.$validate(); + }); + } + + function parseObservedDateValue(val) { + return isDefined(val) ? (isDate(val) ? val : parseDate(val)) : undefined; + } + }; +} + +function badInputChecker(scope, element, attr, ctrl) { + var node = element[0]; + var nativeValidation = ctrl.$$hasNativeValidators = isObject(node.validity); + if (nativeValidation) { + ctrl.$parsers.push(function(value) { + var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; + // Detect bug in FF35 for input[email] (https://bugzilla.mozilla.org/show_bug.cgi?id=1064430): + // - also sets validity.badInput (should only be validity.typeMismatch). + // - see http://www.whatwg.org/specs/web-apps/current-work/multipage/forms.html#e-mail-state-(type=email) + // - can ignore this case as we can still read out the erroneous email... + return validity.badInput && !validity.typeMismatch ? undefined : value; + }); } } -var numberBadFlags = ['badInput']; - function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + badInputChecker(scope, element, attr, ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'number'; ctrl.$parsers.push(function(value) { - var empty = ctrl.$isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; + if (ctrl.$isEmpty(value)) return null; + if (NUMBER_REGEXP.test(value)) return parseFloat(value); + return undefined; + }); + + ctrl.$formatters.push(function(value) { + if (!ctrl.$isEmpty(value)) { + if (!isNumber(value)) { + throw $ngModelMinErr('numfmt', 'Expected `{0}` to be a number', value); + } + value = value.toString(); } + return value; }); - addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState); - - ctrl.$formatters.push(function(value) { - return ctrl.$isEmpty(value) ? '' : '' + value; - }); - - if (attr.min) { - var minValidator = function(value) { - var min = parseFloat(attr.min); - return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + if (attr.min || attr.ngMin) { + var minVal; + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal; }; - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); + attr.$observe('min', function(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val, 10); + } + minVal = isNumber(val) && !isNaN(val) ? val : undefined; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); } - if (attr.max) { - var maxValidator = function(value) { - var max = parseFloat(attr.max); - return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + if (attr.max || attr.ngMax) { + var maxVal; + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal; }; - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); + attr.$observe('max', function(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val, 10); + } + maxVal = isNumber(val) && !isNaN(val) ? val : undefined; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); } - - ctrl.$formatters.push(function(value) { - return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); - }); } function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); - var urlValidator = function(value) { - return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); + ctrl.$$parserName = 'url'; + ctrl.$validators.url = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || URL_REGEXP.test(value); }; - - ctrl.$formatters.push(urlValidator); - ctrl.$parsers.push(urlValidator); } function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + // Note: no badInputChecker here by purpose as `url` is only a validation + // in browsers, i.e. we can always read out input.value even if it is not valid! + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + stringBasedInputType(ctrl); - var emailValidator = function(value) { - return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); + ctrl.$$parserName = 'email'; + ctrl.$validators.email = function(modelValue, viewValue) { + var value = modelValue || viewValue; + return ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value); }; - - ctrl.$formatters.push(emailValidator); - ctrl.$parsers.push(emailValidator); } function radioInputType(scope, element, attr, ctrl) { @@ -17113,13 +18653,13 @@ function radioInputType(scope, element, attr, ctrl) { element.attr('name', nextUid()); } - element.on('click', function() { + var listener = function(ev) { if (element[0].checked) { - scope.$apply(function() { - ctrl.$setViewValue(attr.value); - }); + ctrl.$setViewValue(attr.value, ev && ev.type); } - }); + }; + + element.on('click', listener); ctrl.$render = function() { var value = attr.value; @@ -17129,18 +18669,28 @@ function radioInputType(scope, element, attr, ctrl) { attr.$observe('value', ctrl.$render); } -function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; +function parseConstantExpr($parse, context, name, expression, fallback) { + var parseFn; + if (isDefined(expression)) { + parseFn = $parse(expression); + if (!parseFn.constant) { + throw minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + + '`{1}`.', name, expression); + } + return parseFn(context); + } + return fallback; +} - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; +function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) { + var trueValue = parseConstantExpr($parse, scope, 'ngTrueValue', attr.ngTrueValue, true); + var falseValue = parseConstantExpr($parse, scope, 'ngFalseValue', attr.ngFalseValue, false); - element.on('click', function() { - scope.$apply(function() { - ctrl.$setViewValue(element[0].checked); - }); - }); + var listener = function(ev) { + ctrl.$setViewValue(element[0].checked, ev && ev.type); + }; + + element.on('click', listener); ctrl.$render = function() { element[0].checked = ctrl.$viewValue; @@ -17152,7 +18702,7 @@ function checkboxInputType(scope, element, attr, ctrl) { }; ctrl.$formatters.push(function(value) { - return value === trueValue; + return equals(value, trueValue); }); ctrl.$parsers.push(function(value) { @@ -17199,6 +18749,8 @@ function checkboxInputType(scope, element, attr, ctrl) { * HTML input element control with angular data-binding. Input control follows HTML5 input types * and polyfills the HTML5 validation behavior for older browsers. * + * *NOTE* Not every feature offered is available for all input types. + * * @param {string} ngModel Assignable angular expression to data-bind to. * @param {string=} name Property name of the form under which the control is published. * @param {string=} required Sets `required` validation error key if the value is not entered. @@ -17212,6 +18764,9 @@ function checkboxInputType(scope, element, attr, ctrl) { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * This parameter is ignored for input[type=password] controls, which will never trim the + * input. * * @example @@ -17247,7 +18802,7 @@ function checkboxInputType(scope, element, attr, ctrl) {
    - var user = element(by.binding('{{user}}')); + var user = element(by.exactBinding('user')); var userNameValid = element(by.binding('myForm.userName.$valid')); var lastNameValid = element(by.binding('myForm.lastName.$valid')); var lastNameError = element(by.binding('myForm.lastName.$error')); @@ -17301,14 +18856,15 @@ function checkboxInputType(scope, element, attr, ctrl) { */ -var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { +var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', + function($browser, $sniffer, $filter, $parse) { return { restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, - $browser); + require: ['?ngModel'], + link: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, + $browser, $filter, $parse); } } }; @@ -17317,7 +18873,10 @@ var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { var VALID_CLASS = 'ng-valid', INVALID_CLASS = 'ng-invalid', PRISTINE_CLASS = 'ng-pristine', - DIRTY_CLASS = 'ng-dirty'; + DIRTY_CLASS = 'ng-dirty', + UNTOUCHED_CLASS = 'ng-untouched', + TOUCHED_CLASS = 'ng-touched', + PENDING_CLASS = 'ng-pending'; /** * @ngdoc type @@ -17346,12 +18905,61 @@ var VALID_CLASS = 'ng-valid', * ngModel.$formatters.push(formatter); * ``` * + * @property {Object.} $validators A collection of validators that are applied + * whenever the model value changes. The key value within the object refers to the name of the + * validator while the function refers to the validation operation. The validation operation is + * provided with the model value as an argument and must return a true or false value depending + * on the response of that validation. + * + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` + * + * @property {Object.} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are triggered, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. Also, keep in mind that all + * asynchronous validators will only run once all synchronous validators have passed. + * + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. + * + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * + * // Lookup user by username + * return $http.get('/api/users/' + value). + * then(function resolved() { + * //username exists, this means validation fails + * return $q.reject('exists'); + * }, function rejected() { + * //username does not exist, therefore this validation passes + * return true; + * }); + * }; + * ``` + * + * @param {string} name The name of the validator. + * @param {Function} validationFn The validation function that will be run. + * * @property {Array.} $viewChangeListeners Array of functions to execute whenever the * view value has changed. It is called with no arguments, and its return value is ignored. * This can be used in place of additional $watches against the model value. * - * @property {Object} $error An object hash with all errors as keys. + * @property {Object} $error An object hash with all failing validator ids as keys. + * @property {Object} $pending An object hash with all pending validator ids as keys. * + * @property {boolean} $untouched True if control has not lost focus yet. + * @property {boolean} $touched True if control has lost focus. * @property {boolean} $pristine True if user has not interacted with the control yet. * @property {boolean} $dirty True if user has already interacted with the control. * @property {boolean} $valid True if there is no error. @@ -17398,7 +19006,7 @@ var VALID_CLASS = 'ng-valid', restrict: 'A', // only activate on element attribute require: '?ngModel', // get a hold of NgModelController link: function(scope, element, attrs, ngModel) { - if(!ngModel) return; // do nothing if no ng-model + if (!ngModel) return; // do nothing if no ng-model // Specify how UI should be updated ngModel.$render = function() { @@ -17416,7 +19024,7 @@ var VALID_CLASS = 'ng-valid', var html = element.html(); // When we clear the content editable the browser leaves a
    behind // If strip-br attribute is provided then we strip this out - if( attrs.stripBr && html == '
    ' ) { + if ( attrs.stripBr && html == '
    ' ) { html = ''; } ngModel.$setViewValue(html); @@ -17458,26 +19066,58 @@ var VALID_CLASS = 'ng-valid', * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; + this.$validators = {}; + this.$asyncValidators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; + this.$untouched = true; + this.$touched = false; this.$pristine = true; this.$dirty = false; this.$valid = true; this.$invalid = false; + this.$error = {}; // keep invalid keys here + this.$$success = {}; // keep valid keys here + this.$pending = undefined; // keep pending keys here this.$name = $attr.name; - var ngModelGet = $parse($attr.ngModel), - ngModelSet = ngModelGet.assign; - if (!ngModelSet) { - throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", - $attr.ngModel, startingTag($element)); - } + var parsedNgModel = $parse($attr.ngModel), + pendingDebounce = null, + ctrl = this; + + var ngModelGet = function ngModelGet() { + var modelValue = parsedNgModel($scope); + if (ctrl.$options && ctrl.$options.getterSetter && isFunction(modelValue)) { + modelValue = modelValue(); + } + return modelValue; + }; + + var ngModelSet = function ngModelSet(newValue) { + var getterSetter; + if (ctrl.$options && ctrl.$options.getterSetter && + isFunction(getterSetter = parsedNgModel($scope))) { + + getterSetter(ctrl.$modelValue); + } else { + parsedNgModel.assign($scope, ctrl.$modelValue); + } + }; + + this.$$setOptions = function(options) { + ctrl.$options = options; + + if (!parsedNgModel.assign && (!options || !options.getterSetter)) { + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + $attr.ngModel, startingTag($element)); + } + }; /** * @ngdoc method @@ -17486,6 +19126,18 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @description * Called when the view needs to be updated. It is expected that the user of the ng-model * directive will implement this method. + * + * The `$render()` method is invoked in the following situations: + * + * * `$rollbackViewValue()` is called. If we are rolling back the view value to the last + * committed value then `$render()` is called to update the input control. + * * The value referenced by `ng-model` is changed programmatically and both the `$modelValue` and + * the `$viewValue` are different to last time. + * + * Since `ng-model` does not do a deep watch, `$render()` is only invoked if the values of + * `$modelValue` and `$viewValue` are actually different to their previous value. If `$modelValue` + * or `$viewValue` are objects (rather than a string or number) then `$render()` will not be + * invoked if you only change a property on the objects. */ this.$render = noop; @@ -17511,63 +19163,44 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ }; var parentForm = $element.inheritedData('$formController') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - $error = this.$error = {}; // keep invalid keys here - + currentValidationRunId = 0; // Setup initial state of the control - $element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass($element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } + $element + .addClass(PRISTINE_CLASS) + .addClass(UNTOUCHED_CLASS); /** * @ngdoc method * @name ngModel.NgModelController#$setValidity * * @description - * Change the validity state, and notifies the form when the control changes validity. (i.e. it - * does not notify form if given validator is already marked as invalid). + * Change the validity state, and notifies the form. * - * This method should be called by validators - i.e. the parser or formatter functions. + * This method can be called within $parsers/$formatters. However, if possible, please use the + * `ngModel.$validators` pipeline which is designed to call this method automatically. * * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign - * to `$error[validationErrorKey]=!isValid` so that it is available for data-binding. + * to `$error[validationErrorKey]` and `$pending[validationErrorKey]` + * so that it is available for data-binding. * The `validationErrorKey` should be in camelCase and will get converted into dash-case * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), + * or skipped (null). */ - this.$setValidity = function(validationErrorKey, isValid) { - // Purposeful use of ! here to cast isValid to boolean in case it is undefined - // jshint -W018 - if ($error[validationErrorKey] === !isValid) return; - // jshint +W018 - - if (isValid) { - if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { - toggleValidCss(true); - this.$valid = true; - this.$invalid = false; - } - } else { - toggleValidCss(false); - this.$invalid = true; - this.$valid = false; - invalidCount++; - } - - $error[validationErrorKey] = !isValid; - toggleValidCss(isValid, validationErrorKey); - - parentForm.$setValidity(validationErrorKey, isValid, this); - }; + addSetValidityMethod({ + ctrl: this, + $element: $element, + set: function(object, property) { + object[property] = true; + }, + unset: function(object, property) { + delete object[property]; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method @@ -17577,15 +19210,299 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * Sets the control to its pristine state. * * This method can be called to remove the 'ng-dirty' class and set the control to its pristine - * state (ng-pristine class). + * state (ng-pristine class). A model is considered to be pristine when the model has not been changed + * from when first compiled within then form. */ this.$setPristine = function () { - this.$dirty = false; - this.$pristine = true; + ctrl.$dirty = false; + ctrl.$pristine = true; $animate.removeClass($element, DIRTY_CLASS); $animate.addClass($element, PRISTINE_CLASS); }; + /** + * @ngdoc method + * @name ngModel.NgModelController#$setUntouched + * + * @description + * Sets the control to its untouched state. + * + * This method can be called to remove the 'ng-touched' class and set the control to its + * untouched state (ng-untouched class). Upon compilation, a model is set as untouched + * by default, however this function can be used to restore that state if the model has + * already been touched by the user. + */ + this.$setUntouched = function() { + ctrl.$touched = false; + ctrl.$untouched = true; + $animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setTouched + * + * @description + * Sets the control to its touched state. + * + * This method can be called to remove the 'ng-untouched' class and set the control to its + * touched state (ng-touched class). A model is considered to be touched when the user has + * first interacted (focussed) on the model input element and then shifted focus away (blurred) + * from the input element. + */ + this.$setTouched = function() { + ctrl.$touched = true; + ctrl.$untouched = false; + $animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$rollbackViewValue + * + * @description + * Cancel an update and reset the input element's value to prevent an update to the `$modelValue`, + * which may be caused by a pending debounced event or because the input is waiting for a some + * future event. + * + * If you have an input that uses `ng-model-options` to set up debounced events or events such + * as blur you can have a situation where there is a period when the `$viewValue` + * is out of synch with the ngModel's `$modelValue`. + * + * In this case, you can run into difficulties if you try to update the ngModel's `$modelValue` + * programmatically before these debounced/future events have resolved/occurred, because Angular's + * dirty checking mechanism is not able to tell whether the model has actually changed or not. + * + * The `$rollbackViewValue()` method should be called before programmatically changing the model of an + * input which may have such events pending. This is important in order to make sure that the + * input field will be updated with the new model value and any pending operations are cancelled. + * + * + * + * angular.module('cancel-update-example', []) + * + * .controller('CancelUpdateController', ['$scope', function($scope) { + * $scope.resetWithCancel = function (e) { + * if (e.keyCode == 27) { + * $scope.myForm.myInput1.$rollbackViewValue(); + * $scope.myValue = ''; + * } + * }; + * $scope.resetWithoutCancel = function (e) { + * if (e.keyCode == 27) { + * $scope.myValue = ''; + * } + * }; + * }]); + * + * + *
    + *

    Try typing something in each input. See that the model only updates when you + * blur off the input. + *

    + *

    Now see what happens if you start typing then press the Escape key

    + * + *
    + *

    With $rollbackViewValue()

    + *
    + * myValue: "{{ myValue }}" + * + *

    Without $rollbackViewValue()

    + *
    + * myValue: "{{ myValue }}" + *
    + *
    + *
    + *
    + */ + this.$rollbackViewValue = function() { + $timeout.cancel(pendingDebounce); + ctrl.$viewValue = ctrl.$$lastCommittedViewValue; + ctrl.$render(); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$validate + * + * @description + * Runs each of the registered validators (first synchronous validators and then asynchronous validators). + */ + this.$validate = function() { + // ignore $validate before model is initialized + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + return; + } + this.$$parseAndValidate(); + }; + + this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) { + currentValidationRunId++; + var localValidationRunId = currentValidationRunId; + + // check parser error + if (!processParseErrors(parseValid)) { + return; + } + if (!processSyncValidators()) { + return; + } + processAsyncValidators(); + + function processParseErrors(parseValid) { + var errorKey = ctrl.$$parserName || 'parse'; + if (parseValid === undefined) { + setValidity(errorKey, null); + } else { + setValidity(errorKey, parseValid); + if (!parseValid) { + forEach(ctrl.$validators, function(v, name) { + setValidity(name, null); + }); + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + } + return true; + } + + function processSyncValidators() { + var syncValidatorsValid = true; + forEach(ctrl.$validators, function(validator, name) { + var result = validator(modelValue, viewValue); + syncValidatorsValid = syncValidatorsValid && result; + setValidity(name, result); + }); + if (!syncValidatorsValid) { + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + return true; + } + + function processAsyncValidators() { + var validatorPromises = []; + forEach(ctrl.$asyncValidators, function(validator, name) { + var promise = validator(modelValue, viewValue); + if (!isPromiseLike(promise)) { + throw $ngModelMinErr("$asyncValidators", + "Expected asynchronous validator to return a promise but got '{0}' instead.", promise); + } + setValidity(name, undefined); + validatorPromises.push(promise.then(function() { + setValidity(name, true); + }, function(error) { + setValidity(name, false); + })); + }); + if (!validatorPromises.length) { + validationDone(); + } else { + $q.all(validatorPromises).then(validationDone); + } + } + + function setValidity(name, isValid) { + if (localValidationRunId === currentValidationRunId) { + ctrl.$setValidity(name, isValid); + } + } + + function validationDone() { + if (localValidationRunId === currentValidationRunId) { + + doneCallback(); + } + } + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$commitViewValue + * + * @description + * Commit a pending update to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + this.$commitViewValue = function() { + var viewValue = ctrl.$viewValue; + + $timeout.cancel(pendingDebounce); + + // If the view value has not changed then we should just exit, except in the case where there is + // a native validator on the element. In this case the validation state may have changed even though + // the viewValue has stayed empty. + if (ctrl.$$lastCommittedViewValue === viewValue && (viewValue !== '' || !ctrl.$$hasNativeValidators)) { + return; + } + ctrl.$$lastCommittedViewValue = viewValue; + + // change to dirty + if (ctrl.$pristine) { + ctrl.$dirty = true; + ctrl.$pristine = false; + $animate.removeClass($element, PRISTINE_CLASS); + $animate.addClass($element, DIRTY_CLASS); + parentForm.$setDirty(); + } + this.$$parseAndValidate(); + }; + + this.$$parseAndValidate = function() { + var parserValid = true, + viewValue = ctrl.$$lastCommittedViewValue, + modelValue = viewValue; + for(var i = 0; i < ctrl.$parsers.length; i++) { + modelValue = ctrl.$parsers[i](modelValue); + if (isUndefined(modelValue)) { + parserValid = false; + break; + } + } + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + // ctrl.$modelValue has not been touched yet... + ctrl.$modelValue = ngModelGet(); + } + var prevModelValue = ctrl.$modelValue; + var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + if (allowInvalid) { + ctrl.$modelValue = modelValue; + writeToModelIfNeeded(); + } + ctrl.$$runValidators(parserValid, modelValue, viewValue, function() { + if (!allowInvalid) { + ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + writeToModelIfNeeded(); + } + }); + + function writeToModelIfNeeded() { + if (ctrl.$modelValue !== prevModelValue) { + ctrl.$$writeModelToScope(); + } + } + }; + + this.$$writeModelToScope = function() { + ngModelSet(ctrl.$modelValue); + forEach(ctrl.$viewChangeListeners, function(listener) { + try { + listener(); + } catch(e) { + $exceptionHandler(e); + } + }); + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setViewValue @@ -17593,73 +19510,108 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @description * Update the view value. * - * This method should be called when the view value changes, typically from within a DOM event handler. - * For example {@link ng.directive:input input} and - * {@link ng.directive:select select} directives call it. + * This method should be called when an input directive want to change the view value; typically, + * this is done from within a DOM event handler. * - * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, - * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to - * `$modelValue` and the **expression** specified in the `ng-model` attribute. + * For example {@link ng.directive:input input} calls it when the value of the input changes and + * {@link ng.directive:select select} calls it when an option is selected. + * + * If the new `value` is an object (rather than a string or a number), we should make a copy of the + * object before passing it to `$setViewValue`. This is because `ngModel` does not perform a deep + * watch of objects, it only looks for a change of identity. If you only change the property of + * the object then ngModel will not realise that the object has changed and will not invoke the + * `$parsers` and `$validators` pipelines. + * + * For this reason, you should not change properties of the copy once it has been passed to + * `$setViewValue`. Otherwise you may cause the model value on the scope to change incorrectly. + * + * When this method is called, the new `value` will be staged for committing through the `$parsers` + * and `$validators` pipelines. If there are no special {@link ngModelOptions} specified then the staged + * value sent directly for processing, finally to be applied to `$modelValue` and then the + * **expression** specified in the `ng-model` attribute. * * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. * + * In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn` + * and the `default` trigger is not listed, all those actions will remain pending until one of the + * `updateOn` events is triggered on the DOM element. + * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} + * directive is used with a custom debounce for this particular event. + * * Note that calling this function does not trigger a `$digest`. * * @param {string} value Value from the view. + * @param {string} trigger Event that triggered the update. */ - this.$setViewValue = function(value) { - this.$viewValue = value; + this.$setViewValue = function(value, trigger) { + ctrl.$viewValue = value; + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + ctrl.$$debounceViewValueCommit(trigger); + } + }; - // change to dirty - if (this.$pristine) { - this.$dirty = true; - this.$pristine = false; - $animate.removeClass($element, PRISTINE_CLASS); - $animate.addClass($element, DIRTY_CLASS); - parentForm.$setDirty(); + this.$$debounceViewValueCommit = function(trigger) { + var debounceDelay = 0, + options = ctrl.$options, + debounce; + + if (options && isDefined(options.debounce)) { + debounce = options.debounce; + if (isNumber(debounce)) { + debounceDelay = debounce; + } else if (isNumber(debounce[trigger])) { + debounceDelay = debounce[trigger]; + } else if (isNumber(debounce['default'])) { + debounceDelay = debounce['default']; + } } - forEach(this.$parsers, function(fn) { - value = fn(value); - }); - - if (this.$modelValue !== value) { - this.$modelValue = value; - ngModelSet($scope, value); - forEach(this.$viewChangeListeners, function(listener) { - try { - listener(); - } catch(e) { - $exceptionHandler(e); - } + $timeout.cancel(pendingDebounce); + if (debounceDelay) { + pendingDebounce = $timeout(function() { + ctrl.$commitViewValue(); + }, debounceDelay); + } else if ($rootScope.$$phase) { + ctrl.$commitViewValue(); + } else { + $scope.$apply(function() { + ctrl.$commitViewValue(); }); } }; // model -> value - var ctrl = this; - + // Note: we cannot use a normal scope.$watch as we want to detect the following: + // 1. scope value is 'a' + // 2. user enters 'b' + // 3. ng-change kicks in and reverts scope value to 'a' + // -> scope value did not change since the last digest as + // ng-change executes in apply phase + // 4. view should be changed back to 'a' $scope.$watch(function ngModelWatch() { - var value = ngModelGet($scope); + var modelValue = ngModelGet(); // if scope model value and ngModel value are out of sync - if (ctrl.$modelValue !== value) { + // TODO(perf): why not move this to the action fn? + if (modelValue !== ctrl.$modelValue) { + ctrl.$modelValue = modelValue; var formatters = ctrl.$formatters, idx = formatters.length; - ctrl.$modelValue = value; + var viewValue = modelValue; while(idx--) { - value = formatters[idx](value); + viewValue = formatters[idx](viewValue); } - - if (ctrl.$viewValue !== value) { - ctrl.$viewValue = value; + if (ctrl.$viewValue !== viewValue) { + ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; ctrl.$render(); + + ctrl.$$runValidators(undefined, modelValue, viewValue, noop); } } - return value; + return modelValue; }); }]; @@ -17680,8 +19632,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` * require. * - Providing validation behavior (i.e. required, number, email, url). - * - Keeping the state of the control (valid/invalid, dirty/pristine, validation errors). - * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`) including animations. + * - Keeping the state of the control (valid/invalid, dirty/pristine, touched/untouched, validation errors). + * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`, `ng-touched`, `ng-untouched`) including animations. * - Registering the control with its parent {@link ng.directive:form form}. * * Note: `ngModel` will try to bind to the property given by evaluating the expression on the @@ -17701,6 +19653,11 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * - {@link input[number] number} * - {@link input[email] email} * - {@link input[url] url} + * - {@link input[date] date} + * - {@link input[dateTimeLocal] dateTimeLocal} + * - {@link input[time] time} + * - {@link input[month] month} + * - {@link input[week] week} * - {@link ng.directive:select select} * - {@link ng.directive:textarea textarea} * @@ -17766,22 +19723,91 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * + * + * ## Binding to a getter/setter + * + * Sometimes it's helpful to bind `ngModel` to a getter/setter function. A getter/setter is a + * function that returns a representation of the model when called with zero arguments, and sets + * the internal state of a model when called with an argument. It's sometimes useful to use this + * for models that have an internal representation that's different than what the model exposes + * to the view. + * + *
    + * **Best Practice:** It's best to keep getters fast because Angular is likely to call them more + * frequently than other parts of your code. + *
    + * + * You use this behavior by adding `ng-model-options="{ getterSetter: true }"` to an element that + * has `ng-model` attached to it. You can also add `ng-model-options="{ getterSetter: true }"` to + * a `
    `, which will enable this behavior for all ``s within it. See + * {@link ng.directive:ngModelOptions `ngModelOptions`} for more. + * + * The following example shows how to use `ngModel` with a getter/setter: + * + * @example + * + +
    + + Name: + + +
    user.name = 
    +
    +
    + + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function (newName) { + if (angular.isDefined(newName)) { + _name = newName; + } + return _name; + } + }; + }]); + + *
    */ var ngModelDirective = function() { return { - require: ['ngModel', '^?form'], + restrict: 'A', + require: ['ngModel', '^?form', '^?ngModelOptions'], controller: NgModelController, - link: function(scope, element, attr, ctrls) { - // notify others, especially parent forms + link: { + pre: function(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0], + formCtrl = ctrls[1] || nullFormCtrl; - var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || nullFormCtrl; + modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); - formCtrl.$addControl(modelCtrl); + // notify others, especially parent forms + formCtrl.$addControl(modelCtrl); - scope.$on('$destroy', function() { - formCtrl.$removeControl(modelCtrl); - }); + scope.$on('$destroy', function() { + formCtrl.$removeControl(modelCtrl); + }); + }, + post: function(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0]; + if (modelCtrl.$options && modelCtrl.$options.updateOn) { + element.on(modelCtrl.$options.updateOn, function(ev) { + modelCtrl.$$debounceViewValueCommit(ev && ev.type); + }); + } + + element.on('blur', function(ev) { + if (modelCtrl.$touched) return; + + scope.$apply(function() { + modelCtrl.$setTouched(); + }); + }); + } } }; }; @@ -17796,7 +19822,15 @@ var ngModelDirective = function() { * The expression is evaluated immediately, unlike the JavaScript onchange event * which only triggers at the end of a change (usually, when the user leaves the * form element or presses the return key). - * The expression is not evaluated when the value change is coming from the model. + * + * The `ngChange` expression is only evaluated when a change in the input value causes + * a new value to be committed to the model. + * + * It will not be evaluated: + * * if the value returned from the `$parsers` transformation pipeline has not changed + * * if the input has continued to be invalid since the model will stay `null` + * * if the model is changed programmatically and not by a change to the input value + * * * Note, this directive requires `ngModel` to be present. * @@ -17847,6 +19881,7 @@ var ngModelDirective = function() { * */ var ngChangeDirective = valueFn({ + restrict: 'A', require: 'ngModel', link: function(scope, element, attr, ctrl) { ctrl.$viewChangeListeners.push(function() { @@ -17858,93 +19893,187 @@ var ngChangeDirective = valueFn({ var requiredDirective = function() { return { + restrict: 'A', require: '?ngModel', link: function(scope, elm, attr, ctrl) { if (!ctrl) return; attr.required = true; // force truthy in case we are on non input element - var validator = function(value) { - if (attr.required && ctrl.$isEmpty(value)) { - ctrl.$setValidity('required', false); - return; - } else { - ctrl.$setValidity('required', true); - return value; - } + ctrl.$validators.required = function(modelValue, viewValue) { + return !attr.required || !ctrl.$isEmpty(viewValue); }; - ctrl.$formatters.push(validator); - ctrl.$parsers.unshift(validator); - attr.$observe('required', function() { - validator(ctrl.$viewValue); + ctrl.$validate(); }); } }; }; +var patternDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var regexp, patternExp = attr.ngPattern || attr.pattern; + attr.$observe('pattern', function(regex) { + if (isString(regex) && regex.length > 0) { + regex = new RegExp(regex); + } + + if (regex && !regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); + } + + regexp = regex || undefined; + ctrl.$validate(); + }); + + ctrl.$validators.pattern = function(value) { + return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value); + }; + } + }; +}; + + +var maxlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var maxlength = 0; + attr.$observe('maxlength', function(value) { + maxlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.maxlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length <= maxlength; + }; + } + }; +}; + +var minlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var minlength = 0; + attr.$observe('minlength', function(value) { + minlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.minlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; + }; + } + }; +}; + + /** * @ngdoc directive * @name ngList * * @description - * Text input that converts between a delimited string and an array of strings. The delimiter - * can be a fixed string (by default a comma) or a regular expression. + * Text input that converts between a delimited string and an array of strings. The default + * delimiter is a comma followed by a space - equivalent to `ng-list=", "`. You can specify a custom + * delimiter as the value of the `ngList` attribute - for example, `ng-list=" | "`. + * + * The behaviour of the directive is affected by the use of the `ngTrim` attribute. + * * If `ngTrim` is set to `"false"` then whitespace around both the separator and each + * list item is respected. This implies that the user of the directive is responsible for + * dealing with whitespace but also allows you to use whitespace as a delimiter, such as a + * tab or newline character. + * * Otherwise whitespace around the delimiter is ignored when splitting (although it is respected + * when joining the list items back together) and whitespace around each list item is stripped + * before it is added to the model. + * + * ### Example with Validation + * + * + * + * angular.module('listExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.names = ['morpheus', 'neo', 'trinity']; + * }]); + * + * + *
    + * List: + * + * Required! + *
    + * names = {{names}}
    + * myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
    + * myForm.namesInput.$error = {{myForm.namesInput.$error}}
    + * myForm.$valid = {{myForm.$valid}}
    + * myForm.$error.required = {{!!myForm.$error.required}}
    + *
    + *
    + * + * var listInput = element(by.model('names')); + * var names = element(by.exactBinding('names')); + * var valid = element(by.binding('myForm.namesInput.$valid')); + * var error = element(by.css('span.error')); + * + * it('should initialize to model', function() { + * expect(names.getText()).toContain('["morpheus","neo","trinity"]'); + * expect(valid.getText()).toContain('true'); + * expect(error.getCssValue('display')).toBe('none'); + * }); + * + * it('should be invalid if empty', function() { + * listInput.clear(); + * listInput.sendKeys(''); + * + * expect(names.getText()).toContain(''); + * expect(valid.getText()).toContain('false'); + * expect(error.getCssValue('display')).not.toBe('none'); + * }); + * + *
    + * + * ### Example - splitting on whitespace + * + * + * + *
    {{ list | json }}
    + *
    + * + * it("should split the text by newlines", function() { + * var listInput = element(by.model('list')); + * var output = element(by.binding('list | json')); + * listInput.sendKeys('abc\ndef\nghi'); + * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); + * }); + * + *
    * * @element input - * @param {string=} ngList optional delimiter that should be used to split the value. If - * specified in form `/something/` then the value will be converted into a regular expression. - * - * @example - - - -
    - List: - - Required! -
    - names = {{names}}
    - myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
    - myForm.namesInput.$error = {{myForm.namesInput.$error}}
    - myForm.$valid = {{myForm.$valid}}
    - myForm.$error.required = {{!!myForm.$error.required}}
    -
    -
    - - var listInput = element(by.model('names')); - var names = element(by.binding('{{names}}')); - var valid = element(by.binding('myForm.namesInput.$valid')); - var error = element(by.css('span.error')); - - it('should initialize to model', function() { - expect(names.getText()).toContain('["igor","misko","vojta"]'); - expect(valid.getText()).toContain('true'); - expect(error.getCssValue('display')).toBe('none'); - }); - - it('should be invalid if empty', function() { - listInput.clear(); - listInput.sendKeys(''); - - expect(names.getText()).toContain(''); - expect(valid.getText()).toContain('false'); - expect(error.getCssValue('display')).not.toBe('none'); }); - -
    + * @param {string=} ngList optional delimiter that should be used to split the value. */ var ngListDirective = function() { return { + restrict: 'A', + priority: 100, require: 'ngModel', link: function(scope, element, attr, ctrl) { - var match = /\/(.*)\//.exec(attr.ngList), - separator = match && new RegExp(match[1]) || attr.ngList || ','; + // We want to control whitespace trimming so we use this convoluted approach + // to access the ngList attribute, which doesn't pre-trim the attribute + var ngList = element.attr(attr.$attr.ngList) || ', '; + var trimValues = attr.ngTrim !== 'false'; + var separator = trimValues ? trim(ngList) : ngList; var parse = function(viewValue) { // If the viewValue is invalid (say required but empty) it will be `undefined` @@ -17954,7 +20083,7 @@ var ngListDirective = function() { if (viewValue) { forEach(viewValue.split(separator), function(value) { - if (value) list.push(trim(value)); + if (value) list.push(trimValues ? trim(value) : value); }); } @@ -17964,7 +20093,7 @@ var ngListDirective = function() { ctrl.$parsers.push(parse); ctrl.$formatters.push(function(value) { if (isArray(value)) { - return value.join(', '); + return value.join(ngList); } return undefined; @@ -18034,6 +20163,7 @@ var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; */ var ngValueDirective = function() { return { + restrict: 'A', priority: 100, compile: function(tpl, tplAttr) { if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { @@ -18051,6 +20181,278 @@ var ngValueDirective = function() { }; }; +/** + * @ngdoc directive + * @name ngModelOptions + * + * @description + * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of + * events that will trigger a model update and/or a debouncing delay so that the actual update only + * takes place when a timer expires; this timer will be reset after another change takes place. + * + * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might + * be different than the value in the actual model. This means that if you update the model you + * should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in + * order to make sure it is synchronized with the model and that any debounced action is canceled. + * + * The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`} + * method is by making sure the input is placed inside a form that has a `name` attribute. This is + * important because `form` controllers are published to the related scope under the name in their + * `name` attribute. + * + * Any pending changes will take place immediately when an enclosing form is submitted via the + * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. + * + * @param {Object} ngModelOptions options to apply to the current model. Valid keys are: + * - `updateOn`: string specifying which event should be the input bound to. You can set several + * events using an space delimited list. There is a special event called `default` that + * matches the default events belonging of the control. + * - `debounce`: integer value which contains the debounce model update value in milliseconds. A + * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a + * custom value for each event. For example: + * `ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` + * - `allowInvalid`: boolean value which indicates that the model can be set with values that did + * not validate correctly instead of the default behavior of setting the model to undefined. + * - `getterSetter`: boolean value which determines whether or not to treat functions bound to + `ngModel` as getters/setters. + * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for + * ``, ``, ... . Right now, the only supported value is `'UTC'`, + * otherwise the default timezone of the browser will be used. + * + * @example + + The following example shows how to override immediate updates. Changes on the inputs within the + form will update the model only when the control loses focus (blur event). If `escape` key is + pressed while the input field is focused, the value is reset to the value in the current model. + + + +
    +
    + Name: +
    + + Other data: +
    +
    +
    user.name = 
    +
    +
    + + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say', data: '' }; + + $scope.cancel = function (e) { + if (e.keyCode == 27) { + $scope.userForm.userName.$rollbackViewValue(); + } + }; + }]); + + + var model = element(by.binding('user.name')); + var input = element(by.model('user.name')); + var other = element(by.model('user.data')); + + it('should allow custom events', function() { + input.sendKeys(' hello'); + input.click(); + expect(model.getText()).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say hello'); + }); + + it('should $rollbackViewValue when model changes', function() { + input.sendKeys(' hello'); + expect(input.getAttribute('value')).toEqual('say hello'); + input.sendKeys(protractor.Key.ESCAPE); + expect(input.getAttribute('value')).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say'); + }); + +
    + + This one shows how to debounce model changes. Model will be updated only 1 sec after last change. + If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. + + + +
    +
    + Name: + +
    +
    +
    user.name = 
    +
    +
    + + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say' }; + }]); + +
    + + This one shows how to bind to getter/setters: + + + +
    +
    + Name: + +
    +
    user.name = 
    +
    +
    + + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function (newName) { + return angular.isDefined(newName) ? (_name = newName) : _name; + } + }; + }]); + +
    + */ +var ngModelOptionsDirective = function() { + return { + restrict: 'A', + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; + this.$options = $scope.$eval($attrs.ngModelOptions); + // Allow adding/overriding bound events + if (this.$options.updateOn !== undefined) { + this.$options.updateOnDefault = false; + // extract "default" pseudo-event from list of events that can trigger a model update + this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() { + that.$options.updateOnDefault = true; + return ' '; + })); + } else { + this.$options.updateOnDefault = true; + } + }] + }; +}; + +// helper methods +function addSetValidityMethod(context) { + var ctrl = context.ctrl, + $element = context.$element, + classCache = {}, + set = context.set, + unset = context.unset, + parentForm = context.parentForm, + $animate = context.$animate; + + ctrl.$setValidity = setValidity; + toggleValidationCss('', true); + + function setValidity(validationErrorKey, state, options) { + if (state === undefined) { + createAndSet('$pending', validationErrorKey, options); + } else { + unsetAndCleanup('$pending', validationErrorKey, options); + } + if (!isBoolean(state)) { + unset(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } else { + if (state) { + unset(ctrl.$error, validationErrorKey, options); + set(ctrl.$$success, validationErrorKey, options); + } else { + set(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } + } + if (ctrl.$pending) { + cachedToggleClass(PENDING_CLASS, true); + ctrl.$valid = ctrl.$invalid = undefined; + toggleValidationCss('', null); + } else { + cachedToggleClass(PENDING_CLASS, false); + ctrl.$valid = isObjectEmpty(ctrl.$error); + ctrl.$invalid = !ctrl.$valid; + toggleValidationCss('', ctrl.$valid); + } + + // re-read the state as the set/unset methods could have + // combined state in ctrl.$error[validationError] (used for forms), + // where setting/unsetting only increments/decrements the value, + // and does not replace it. + var combinedState; + if (ctrl.$pending && ctrl.$pending[validationErrorKey]) { + combinedState = undefined; + } else if (ctrl.$error[validationErrorKey]) { + combinedState = false; + } else if (ctrl.$$success[validationErrorKey]) { + combinedState = true; + } else { + combinedState = null; + } + toggleValidationCss(validationErrorKey, combinedState); + parentForm.$setValidity(validationErrorKey, combinedState, ctrl); + } + + function createAndSet(name, value, options) { + if (!ctrl[name]) { + ctrl[name] = {}; + } + set(ctrl[name], value, options); + } + + function unsetAndCleanup(name, value, options) { + if (ctrl[name]) { + unset(ctrl[name], value, options); + } + if (isObjectEmpty(ctrl[name])) { + ctrl[name] = undefined; + } + } + + function cachedToggleClass(className, switchValue) { + if (switchValue && !classCache[className]) { + $animate.addClass($element, className); + classCache[className] = true; + } else if (!switchValue && classCache[className]) { + $animate.removeClass($element, className); + classCache[className] = false; + } + } + + function toggleValidationCss(validationErrorKey, isValid) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + + cachedToggleClass(VALID_CLASS + validationErrorKey, isValid === true); + cachedToggleClass(INVALID_CLASS + validationErrorKey, isValid === false); + } +} + +function isObjectEmpty(obj) { + if (obj) { + for (var prop in obj) { + return false; + } + } + return true; +} + /** * @ngdoc directive * @name ngBind @@ -18064,7 +20466,7 @@ var ngValueDirective = function() { * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like * `{{ expression }}` which is similar but less verbose. * - * It is preferable to use `ngBind` instead of `{{ expression }}` when a template is momentarily + * It is preferable to use `ngBind` instead of `{{ expression }}` if a template is momentarily * displayed by the browser in its raw state before Angular compiles it. Since `ngBind` is an * element attribute, it makes the bindings invisible to the user while the page is loading. * @@ -18102,20 +20504,23 @@ var ngValueDirective = function() { */ -var ngBindDirective = ngDirective({ - compile: function(templateElement) { - templateElement.addClass('ng-binding'); - return function (scope, element, attr) { - element.data('$binding', attr.ngBind); - scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - // We are purposefully using == here rather than === because we want to - // catch when value is "null or undefined" - // jshint -W041 - element.text(value == undefined ? '' : value); - }); - }; - } -}); +var ngBindDirective = ['$compile', function($compile) { + return { + restrict: 'AC', + compile: function ngBindCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBind); + scope.$watch(attr.ngBind, function ngBindWatchAction(value) { + // We are purposefully using == here rather than === because we want to + // catch when value is "null or undefined" + // jshint -W041 + element.text(value == undefined ? '' : value); + }); + }; + } + }; +}]; /** @@ -18169,14 +20574,18 @@ var ngBindDirective = ngDirective({ */ -var ngBindTemplateDirective = ['$interpolate', function($interpolate) { - return function(scope, element, attr) { - // TODO: move this to scenario runner - var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - element.addClass('ng-binding').data('$binding', interpolateFn); - attr.$observe('ngBindTemplate', function(value) { - element.text(value); - }); +var ngBindTemplateDirective = ['$interpolate', '$compile', function($interpolate, $compile) { + return { + compile: function ngBindTemplateCompile(templateElement) { + $compile.$$addBindingClass(templateElement); + return function ngBindTemplateLink(scope, element, attr) { + var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); + $compile.$$addBindingInfo(element, interpolateFn.expressions); + attr.$observe('ngBindTemplate', function(value) { + element.text(value); + }); + }; + } }; }]; @@ -18201,7 +20610,6 @@ var ngBindTemplateDirective = ['$interpolate', function($interpolate) { * @param {expression} ngBindHtml {@link guide/expression Expression} to evaluate. * * @example - Try it here: enter text in text box and watch the greeting change. @@ -18227,16 +20635,26 @@ var ngBindTemplateDirective = ['$interpolate', function($interpolate) { */ -var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { - return function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBindHtml); +var ngBindHtmlDirective = ['$sce', '$parse', '$compile', function($sce, $parse, $compile) { + return { + restrict: 'A', + compile: function ngBindHtmlCompile(tElement, tAttrs) { + var ngBindHtmlGetter = $parse(tAttrs.ngBindHtml); + var ngBindHtmlWatch = $parse(tAttrs.ngBindHtml, function getStringValue(value) { + return (value || '').toString(); + }); + $compile.$$addBindingClass(tElement); - var parsed = $parse(attr.ngBindHtml); - function getStringValue() { return (parsed(scope) || '').toString(); } + return function ngBindHtmlLink(scope, element, attr) { + $compile.$$addBindingInfo(element, attr.ngBindHtml); - scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) { - element.html($sce.getTrustedHtml(parsed(scope)) || ''); - }); + scope.$watch(ngBindHtmlWatch, function ngBindHtmlWatchAction() { + // we re-evaluate the expr because we want a TrustedValueHolderType + // for $sce, not a string + element.html($sce.getTrustedHtml(ngBindHtmlGetter(scope)) || ''); + }); + }; + } }; }]; @@ -18296,15 +20714,13 @@ function classDirective(name, selector) { function updateClasses (oldClasses, newClasses) { var toAdd = arrayDifference(newClasses, oldClasses); var toRemove = arrayDifference(oldClasses, newClasses); - toRemove = digestClassCounts(toRemove, -1); toAdd = digestClassCounts(toAdd, 1); - - if (toAdd.length === 0) { - $animate.removeClass(element, toRemove); - } else if (toRemove.length === 0) { + toRemove = digestClassCounts(toRemove, -1); + if (toAdd && toAdd.length) { $animate.addClass(element, toAdd); - } else { - $animate.setClass(element, toAdd, toRemove); + } + if (toRemove && toRemove.length) { + $animate.removeClass(element, toRemove); } } @@ -18684,10 +21100,16 @@ var ngCloakDirective = ngDirective({ * * @element ANY * @scope - * @param {expression} ngController Name of a globally accessible constructor function or an - * {@link guide/expression expression} that on the current scope evaluates to a - * constructor function. The controller instance can be published into a scope property - * by specifying `as propertyName`. + * @param {expression} ngController Name of a constructor function registered with the current + * {@link ng.$controllerProvider $controllerProvider} or an {@link guide/expression expression} + * that on the current scope evaluates to a constructor function. + * + * The controller instance can be published into a scope property by specifying + * `ng-controller="as propertyName"`. + * + * If the current `$controllerProvider` is configured to use globals (via + * {@link ng.$controllerProvider#allowGlobals `$controllerProvider.allowGlobals()` }), this may + * also be the name of a globally accessible constructor function (not recommended). * * @example * Here is a simple form for editing user contact information. Adding, removing, clearing, and @@ -18882,6 +21304,7 @@ var ngCloakDirective = ngDirective({ */ var ngControllerDirective = [function() { return { + restrict: 'A', scope: true, controller: '@', priority: 500 @@ -18899,8 +21322,10 @@ var ngControllerDirective = [function() { * This is necessary when developing things like Google Chrome Extensions. * * CSP forbids apps to use `eval` or `Function(string)` generated functions (among other things). - * For us to be compatible, we just need to implement the "getterFn" in $parse without violating - * any of these restrictions. + * For Angular to be CSP compatible there are only two things that we need to do differently: + * + * - don't use `Function` constructor to generate optimized value getters + * - don't inject custom stylesheet into the document * * AngularJS uses `Function(string)` generated functions as a speed optimization. Applying the `ngCsp` * directive will cause Angular to use CSP compatibility mode. When this mode is on AngularJS will @@ -18911,7 +21336,18 @@ var ngControllerDirective = [function() { * includes some CSS rules (e.g. {@link ng.directive:ngCloak ngCloak}). * To make those directives work in CSP mode, include the `angular-csp.css` manually. * - * In order to use this feature put the `ngCsp` directive on the root element of the application. + * Angular tries to autodetect if CSP is active and automatically turn on the CSP-safe mode. This + * autodetection however triggers a CSP error to be logged in the console: + * + * ``` + * Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of + * script in the following Content Security Policy directive: "default-src 'self'". Note that + * 'script-src' was not explicitly set, so 'default-src' is used as a fallback. + * ``` + * + * This error is harmless but annoying. To prevent the error from showing up, put the `ngCsp` + * directive on the root element of the application or on the `angular.js` script tag, whichever + * appears first in the html document. * * *Note: This directive is only available in the `ng-csp` and `data-ng-csp` attribute form.* * @@ -18926,9 +21362,9 @@ var ngControllerDirective = [function() { ``` */ -// ngCsp is not implemented as a proper directive any more, because we need it be processed while we bootstrap -// the system (before $parse is instantiated), for this reason we just have a csp() fn that looks for ng-csp attribute -// anywhere in the current doc +// ngCsp is not implemented as a proper directive any more, because we need it be processed while we +// bootstrap the system (before $parse is instantiated), for this reason we just have +// the csp.isActive() fn that looks for ng-csp attribute anywhere in the current doc /** * @ngdoc directive @@ -18949,7 +21385,9 @@ var ngControllerDirective = [function() { - count: {{count}} + + count: {{count}} + it('should check ng-click', function() { @@ -18967,19 +21405,34 @@ var ngControllerDirective = [function() { * Events that are handled via these handler are always configured not to propagate further. */ var ngEventDirectives = {}; + +// For events that might fire synchronously during DOM manipulation +// we need to execute their event handlers asynchronously using $evalAsync, +// so that they are not executed in an inconsistent state. +var forceAsyncEvents = { + 'blur': true, + 'focus': true +}; forEach( 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), function(name) { var directiveName = directiveNormalize('ng-' + name); - ngEventDirectives[directiveName] = ['$parse', function($parse) { + ngEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse, $rootScope) { return { + restrict: 'A', compile: function($element, attr) { var fn = $parse(attr[directiveName]); return function ngEventHandler(scope, element) { - element.on(lowercase(name), function(event) { - scope.$apply(function() { + var eventName = lowercase(name); + element.on(eventName, function(event) { + var callback = function() { fn(scope, {$event:event}); - }); + }; + if (forceAsyncEvents[eventName] && $rootScope.$$phase) { + scope.$evalAsync(callback); + } else { + scope.$apply(callback); + } }); }; } @@ -19237,6 +21690,13 @@ forEach( * server and reloading the current page), but only if the form does not contain `action`, * `data-action`, or `x-action` attributes. * + *
    + * **Warning:** Be careful not to cause "double-submission" by using both the `ngClick` and + * `ngSubmit` handlers together. See the + * {@link form#submitting-a-form-and-preventing-the-default-action `form` directive documentation} + * for a detailed discussion of when `ngSubmit` may be triggered. + *
    + * * @element form * @priority 0 * @param {expression} ngSubmit {@link guide/expression Expression} to eval. @@ -19289,6 +21749,10 @@ forEach( * @description * Specify custom behavior on focus event. * + * Note: As the `focus` event is executed synchronously when calling `input.focus()` + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon @@ -19305,6 +21769,14 @@ forEach( * @description * Specify custom behavior on blur event. * + * A [blur event](https://developer.mozilla.org/en-US/docs/Web/Events/blur) fires when + * an element has lost focus. + * + * Note: As the `blur` event is executed synchronously also during DOM manipulations + * (e.g. removing a focussed input), + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon @@ -19455,6 +21927,7 @@ forEach( */ var ngIfDirective = ['$animate', function($animate) { return { + multiElement: true, transclude: 'element', priority: 600, terminal: true, @@ -19464,10 +21937,10 @@ var ngIfDirective = ['$animate', function($animate) { var block, childScope, previousElements; $scope.$watch($attr.ngIf, function ngIfWatchAction(value) { - if (toBoolean(value)) { + if (value) { if (!childScope) { - childScope = $scope.$new(); - $transclude(childScope, function (clone) { + $transclude(function (clone, newScope) { + childScope = newScope; clone[clone.length++] = document.createComment(' end ngIf: ' + $attr.ngIf + ' '); // Note: We only need the first/last node of the cloned nodes. // However, we need to keep the reference to the jqlite wrapper as it might be changed later @@ -19488,8 +21961,8 @@ var ngIfDirective = ['$animate', function($animate) { childScope = null; } if(block) { - previousElements = getBlockElements(block.clone); - $animate.leave(previousElements, function() { + previousElements = getBlockNodes(block.clone); + $animate.leave(previousElements).then(function() { previousElements = null; }); block = null; @@ -19660,8 +22133,17 @@ var ngIfDirective = ['$animate', function($animate) { * @description * Emitted every time the ngInclude content is reloaded. */ -var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate', '$sce', - function($http, $templateCache, $anchorScroll, $animate, $sce) { + + +/** + * @ngdoc event + * @name ngInclude#$includeContentError + * @eventType emit on the scope ngInclude was declared in + * @description + * Emitted when a template HTTP request yields an erronous response (status < 200 || status > 299) + */ +var ngIncludeDirective = ['$templateRequest', '$anchorScroll', '$animate', '$sce', + function($templateRequest, $anchorScroll, $animate, $sce) { return { restrict: 'ECA', priority: 400, @@ -19689,7 +22171,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope = null; } if(currentElement) { - $animate.leave(currentElement, function() { + $animate.leave(currentElement).then(function() { previousElement = null; }); previousElement = currentElement; @@ -19706,7 +22188,9 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' var thisChangeId = ++changeCounter; if (src) { - $http.get(src, {cache: $templateCache}).success(function(response) { + //set the 2nd param to true to ignore the template request error so that the inner + //contents and scope can be cleaned up. + $templateRequest(src, true).then(function(response) { if (thisChangeId !== changeCounter) return; var newScope = scope.$new(); ctrl.template = response; @@ -19719,7 +22203,7 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' // directives to non existing elements. var clone = $transclude(newScope, function(clone) { cleanupLastIncludeContent(); - $animate.enter(clone, null, $element, afterAnimation); + $animate.enter(clone, null, $element).then(afterAnimation); }); currentScope = newScope; @@ -19727,8 +22211,11 @@ var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$animate' currentScope.$emit('$includeContentLoaded'); scope.$eval(onloadExp); - }).error(function() { - if (thisChangeId === changeCounter) cleanupLastIncludeContent(); + }, function() { + if (thisChangeId === changeCounter) { + cleanupLastIncludeContent(); + scope.$emit('$includeContentError'); + } }); scope.$emit('$includeContentRequested'); } else { @@ -19753,6 +22240,18 @@ var ngIncludeFillContentDirective = ['$compile', priority: -400, require: 'ngInclude', link: function(scope, $element, $attr, ctrl) { + if (/SVG/.test($element[0].toString())) { + // WebKit: https://bugs.webkit.org/show_bug.cgi?id=135698 --- SVG elements do not + // support innerHTML, so detect this here and try to generate the contents + // specially. + $element.empty(); + $compile(jqLiteBuildFragment(ctrl.template, document).childNodes)(scope, + function namespaceAdaptedClone(clone) { + $element.append(clone); + }, undefined, undefined, $element); + return; + } + $element.html(ctrl.template); $compile($element.contents())(scope); } @@ -20065,7 +22564,7 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp //if explicit number rule such as 1, 2, 3... is defined, just use it. Otherwise, //check it against pluralization rules in $locale service if (!(value in whens)) value = $locale.pluralCat(value - offset); - return whensExpFns[value](scope, element, true); + return whensExpFns[value](scope); } else { return ''; } @@ -20176,6 +22675,13 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp * For example: `item in items` is equivalent to `item in items track by $id(item)`. This implies that the DOM elements * will be associated by item identity in the array. * + * * `variable in expression as alias_expression` – You can also provide an optional alias expression which will then store the + * intermediate results of the repeater after the filters have been applied. Typically this is used to render a special message + * when a filter is active on the repeater, but the filtered result set is empty. + * + * For example: `item in items | filter:x as results` will store the fragment of the repeated items as `results`, but only after + * the items have been processed through the filter. + * * For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements * with the corresponding item in the array by identity. Moving the same object in array would move the DOM @@ -20208,9 +22714,12 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp I have {{friends.length}} friends. They are:
      -
    • +
    • [{{$index + 1}}] {{friend.name}} who is {{friend.age}} years old.
    • +
    • + No results found... +
    @@ -20277,29 +22786,84 @@ var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interp var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { var NG_REMOVED = '$$NG_REMOVED'; var ngRepeatMinErr = minErr('ngRepeat'); + + var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength) { + // TODO(perf): generate setters to shave off ~40ms or 1-1.5% + scope[valueIdentifier] = value; + if (keyIdentifier) scope[keyIdentifier] = key; + scope.$index = index; + scope.$first = (index === 0); + scope.$last = (index === (arrayLength - 1)); + scope.$middle = !(scope.$first || scope.$last); + // jshint bitwise: false + scope.$odd = !(scope.$even = (index&1) === 0); + // jshint bitwise: true + }; + + var getBlockStart = function(block) { + return block.clone[0]; + }; + + var getBlockEnd = function(block) { + return block.clone[block.clone.length - 1]; + }; + + return { + restrict: 'A', + multiElement: true, transclude: 'element', priority: 1000, terminal: true, $$tlb: true, - link: function($scope, $element, $attr, ctrl, $transclude){ - var expression = $attr.ngRepeat; - var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/), - trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, - lhs, rhs, valueIdentifier, keyIdentifier, - hashFnLocals = {$id: hashKey}; + compile: function ngRepeatCompile($element, $attr) { + var expression = $attr.ngRepeat; + var ngRepeatEndComment = document.createComment(' end ngRepeat: ' + expression + ' '); - if (!match) { - throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", + var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); + + if (!match) { + throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", expression); - } + } - lhs = match[1]; - rhs = match[2]; - trackByExp = match[3]; + var lhs = match[1]; + var rhs = match[2]; + var aliasAs = match[3]; + var trackByExp = match[4]; - if (trackByExp) { - trackByExpGetter = $parse(trackByExp); + match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + + if (!match) { + throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", + lhs); + } + var valueIdentifier = match[3] || match[1]; + var keyIdentifier = match[2]; + + if (aliasAs && (!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(aliasAs) || + /^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent)$/.test(aliasAs))) { + throw ngRepeatMinErr('badident', "alias '{0}' is invalid --- must be a valid JS identifier which is not a reserved name.", + aliasAs); + } + + var trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn; + var hashFnLocals = {$id: hashKey}; + + if (trackByExp) { + trackByExpGetter = $parse(trackByExp); + } else { + trackByIdArrayFn = function (key, value) { + return hashKey(value); + }; + trackByIdObjFn = function (key) { + return key; + }; + } + + return function ngRepeatLink($scope, $element, $attr, ctrl, $transclude) { + + if (trackByExpGetter) { trackByIdExpFn = function(key, value, index) { // assign key, value, and $index to the locals so that they can be used in hash functions if (keyIdentifier) hashFnLocals[keyIdentifier] = key; @@ -20307,48 +22871,39 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { hashFnLocals.$index = index; return trackByExpGetter($scope, hashFnLocals); }; - } else { - trackByIdArrayFn = function(key, value) { - return hashKey(value); - }; - trackByIdObjFn = function(key) { - return key; - }; } - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", - lhs); - } - valueIdentifier = match[3] || match[1]; - keyIdentifier = match[2]; - // Store a list of elements from previous run. This is a hash where key is the item from the // iterator, and the value is objects with following properties. // - scope: bound scope // - element: previous element. // - index: position - var lastBlockMap = {}; + // + // We are using no-proto object so that we don't need to guard against inherited props via + // hasOwnProperty. + var lastBlockMap = createMap(); //watch props - $scope.$watchCollection(rhs, function ngRepeatAction(collection){ + $scope.$watchCollection(rhs, function ngRepeatAction(collection) { var index, length, - previousNode = $element[0], // current position of the node + previousNode = $element[0], // node that cloned nodes should be inserted after + // initialized to the comment node anchor nextNode, // Same as lastBlockMap but it has the current state. It will become the // lastBlockMap on the next iteration. - nextBlockMap = {}, - arrayLength, - childScope, + nextBlockMap = createMap(), + collectionLength, key, value, // key/value of iteration trackById, trackByIdFn, collectionKeys, block, // last object information {scope, element, id} - nextBlockOrder = [], + nextBlockOrder, elementsToRemove; + if (aliasAs) { + $scope[aliasAs] = collection; + } if (isArrayLike(collection)) { collectionKeys = collection; @@ -20357,118 +22912,106 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { trackByIdFn = trackByIdExpFn || trackByIdObjFn; // if object, extract keys, sort them and use to determine order of iteration over obj props collectionKeys = []; - for (key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - collectionKeys.push(key); + for (var itemKey in collection) { + if (collection.hasOwnProperty(itemKey) && itemKey.charAt(0) != '$') { + collectionKeys.push(itemKey); } } collectionKeys.sort(); } - arrayLength = collectionKeys.length; + collectionLength = collectionKeys.length; + nextBlockOrder = new Array(collectionLength); // locate existing items - length = nextBlockOrder.length = collectionKeys.length; - for(index = 0; index < length; index++) { - key = (collection === collectionKeys) ? index : collectionKeys[index]; - value = collection[key]; - trackById = trackByIdFn(key, value, index); - assertNotHasOwnProperty(trackById, '`track by` id'); - if(lastBlockMap.hasOwnProperty(trackById)) { - block = lastBlockMap[trackById]; - delete lastBlockMap[trackById]; - nextBlockMap[trackById] = block; - nextBlockOrder[index] = block; - } else if (nextBlockMap.hasOwnProperty(trackById)) { - // restore lastBlockMap - forEach(nextBlockOrder, function(block) { - if (block && block.scope) lastBlockMap[block.id] = block; - }); - // This is a duplicate and we need to throw an error - throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}", - expression, trackById); - } else { - // new never before seen block - nextBlockOrder[index] = { id: trackById }; - nextBlockMap[trackById] = false; - } - } - - // remove existing items - for (key in lastBlockMap) { - // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn - if (lastBlockMap.hasOwnProperty(key)) { - block = lastBlockMap[key]; - elementsToRemove = getBlockElements(block.clone); - $animate.leave(elementsToRemove); - forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; }); - block.scope.$destroy(); + for (index = 0; index < collectionLength; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + trackById = trackByIdFn(key, value, index); + if (lastBlockMap[trackById]) { + // found previously seen block + block = lastBlockMap[trackById]; + delete lastBlockMap[trackById]; + nextBlockMap[trackById] = block; + nextBlockOrder[index] = block; + } else if (nextBlockMap[trackById]) { + // if collision detected. restore lastBlockMap and throw an error + forEach(nextBlockOrder, function (block) { + if (block && block.scope) lastBlockMap[block.id] = block; + }); + throw ngRepeatMinErr('dupes', + "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}", + expression, trackById, toJson(value)); + } else { + // new never before seen block + nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined}; + nextBlockMap[trackById] = true; } } + // remove leftover items + for (var blockKey in lastBlockMap) { + block = lastBlockMap[blockKey]; + elementsToRemove = getBlockNodes(block.clone); + $animate.leave(elementsToRemove); + if (elementsToRemove[0].parentNode) { + // if the element was not removed yet because of pending animation, mark it as deleted + // so that we can ignore it later + for (index = 0, length = elementsToRemove.length; index < length; index++) { + elementsToRemove[index][NG_REMOVED] = true; + } + } + block.scope.$destroy(); + } + // we are not using forEach for perf reasons (trying to avoid #call) - for (index = 0, length = collectionKeys.length; index < length; index++) { + for (index = 0; index < collectionLength; index++) { key = (collection === collectionKeys) ? index : collectionKeys[index]; value = collection[key]; block = nextBlockOrder[index]; - if (nextBlockOrder[index - 1]) previousNode = getBlockEnd(nextBlockOrder[index - 1]); if (block.scope) { // if we have already seen this object, then we need to reuse the // associated scope/element - childScope = block.scope; nextNode = previousNode; + + // skip nodes that are already pending removal via leave animation do { nextNode = nextNode.nextSibling; - } while(nextNode && nextNode[NG_REMOVED]); + } while (nextNode && nextNode[NG_REMOVED]); if (getBlockStart(block) != nextNode) { // existing item which got moved - $animate.move(getBlockElements(block.clone), null, jqLite(previousNode)); + $animate.move(getBlockNodes(block.clone), null, jqLite(previousNode)); } previousNode = getBlockEnd(block); + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); } else { // new item which we don't know about - childScope = $scope.$new(); - } + $transclude(function ngRepeatTransclude(clone, scope) { + block.scope = scope; + // http://jsperf.com/clone-vs-createcomment + var endNode = ngRepeatEndComment.cloneNode(false); + clone[clone.length++] = endNode; - childScope[valueIdentifier] = value; - if (keyIdentifier) childScope[keyIdentifier] = key; - childScope.$index = index; - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - // jshint bitwise: false - childScope.$odd = !(childScope.$even = (index&1) === 0); - // jshint bitwise: true - - if (!block.scope) { - $transclude(childScope, function(clone) { - clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' '); + // TODO(perf): support naked previousNode in `enter` to avoid creation of jqLite wrapper? $animate.enter(clone, null, jqLite(previousNode)); - previousNode = clone; - block.scope = childScope; + previousNode = endNode; // Note: We only need the first/last node of the cloned nodes. // However, we need to keep the reference to the jqlite wrapper as it might be changed later // by a directive with templateUrl when its template arrives. block.clone = clone; nextBlockMap[block.id] = block; + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); }); } } lastBlockMap = nextBlockMap; }); + }; } }; - - function getBlockStart(block) { - return block.clone[0]; - } - - function getBlockEnd(block) { - return block.clone[block.clone.length - 1]; - } }]; /** @@ -20490,15 +23033,10 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { *
    * ``` * - * When the ngShow expression evaluates to false then the ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When true, the ng-hide CSS class is removed + * When the ngShow expression evaluates to a falsy value then the ng-hide CSS class is added to the class + * attribute on the element causing it to become hidden. When truthy, the ng-hide CSS class is removed * from the element causing the element not to appear hidden. * - *
    - * **Note:** Here is a list of values that ngShow will consider as a falsy value (case insensitive):
    - * "f" / "0" / "false" / "no" / "n" / "[]" - *
    - * * ## Why is !important used? * * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector @@ -20518,7 +23056,7 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * * ```css * .ng-hide { - * //this is just another form of hiding an element + * /* this is just another form of hiding an element */ * display:block!important; * position:absolute; * top:-9999px; @@ -20540,7 +23078,15 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * //a working example can be found at the bottom of this page * // * .my-element.ng-hide-add, .my-element.ng-hide-remove { - * transition:0.5s linear all; + * /* this is required as of 1.3x to properly + * apply all styling in a show/hide animation */ + * transition:0s linear all; + * } + * + * .my-element.ng-hide-add-active, + * .my-element.ng-hide-remove-active { + * /* the transition is defined in the active class */ + * transition:1s linear all; * } * * .my-element.ng-hide-add { ... } @@ -20549,7 +23095,7 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * Keep in mind that, as of AngularJS version 1.3.0-beta.11, there is no need to change the display * property to block during animation states--ngAnimate will handle the style toggling automatically for you. * * @animations @@ -20582,8 +23128,6 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { .animate-show { - -webkit-transition:all linear 0.5s; - transition:all linear 0.5s; line-height:20px; opacity:1; padding:10px; @@ -20591,6 +23135,12 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { background:white; } + .animate-show.ng-hide-add.ng-hide-add-active, + .animate-show.ng-hide-remove.ng-hide-remove-active { + -webkit-transition:all linear 0.5s; + transition:all linear 0.5s; + } + .animate-show.ng-hide { line-height:0; opacity:0; @@ -20620,10 +23170,14 @@ var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { */ var ngShowDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngShow, function ngShowWatchAction(value){ - $animate[toBoolean(value) ? 'removeClass' : 'addClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngShow, function ngShowWatchAction(value){ + $animate[value ? 'removeClass' : 'addClass'](element, 'ng-hide'); + }); + } }; }]; @@ -20647,15 +23201,10 @@ var ngShowDirective = ['$animate', function($animate) { *
    * ``` * - * When the ngHide expression evaluates to true then the .ng-hide CSS class is added to the class attribute - * on the element causing it to become hidden. When false, the ng-hide CSS class is removed + * When the ngHide expression evaluates to a truthy value then the .ng-hide CSS class is added to the class + * attribute on the element causing it to become hidden. When falsy, the ng-hide CSS class is removed * from the element causing the element not to appear hidden. * - *
    - * **Note:** Here is a list of values that ngHide will consider as a falsy value (case insensitive):
    - * "f" / "0" / "false" / "no" / "n" / "[]" - *
    - * * ## Why is !important used? * * You may be wondering why !important is used for the .ng-hide CSS class. This is because the `.ng-hide` selector @@ -20675,7 +23224,7 @@ var ngShowDirective = ['$animate', function($animate) { * * ```css * .ng-hide { - * //this is just another form of hiding an element + * /* this is just another form of hiding an element */ * display:block!important; * position:absolute; * top:-9999px; @@ -20705,7 +23254,7 @@ var ngShowDirective = ['$animate', function($animate) { * .my-element.ng-hide-remove.ng-hide-remove-active { ... } * ``` * - * Keep in mind that, as of AngularJS version 1.2.17 (and 1.3.0-beta.11), there is no need to change the display + * Keep in mind that, as of AngularJS version 1.3.0-beta.11, there is no need to change the display * property to block during animation states--ngAnimate will handle the style toggling automatically for you. * * @animations @@ -20776,10 +23325,14 @@ var ngShowDirective = ['$animate', function($animate) { */ var ngHideDirective = ['$animate', function($animate) { - return function(scope, element, attr) { - scope.$watch(attr.ngHide, function ngHideWatchAction(value){ - $animate[toBoolean(value) ? 'addClass' : 'removeClass'](element, 'ng-hide'); - }); + return { + restrict: 'A', + multiElement: true, + link: function(scope, element, attr) { + scope.$watch(attr.ngHide, function ngHideWatchAction(value){ + $animate[value ? 'addClass' : 'removeClass'](element, 'ng-hide'); + }); + } }; }]; @@ -20880,7 +23433,7 @@ var ngStyleDirective = ngDirective(function(scope, element, attr) { * * * @scope - * @priority 800 + * @priority 1200 * @param {*} ngSwitch|on expression to match against ng-switch-when. * On child elements add: * @@ -20979,37 +23532,39 @@ var ngSwitchDirective = ['$animate', function($animate) { var watchExpr = attr.ngSwitch || attr.on, selectedTranscludes = [], selectedElements = [], - previousElements = [], + previousLeaveAnimations = [], selectedScopes = []; + var spliceFactory = function(array, index) { + return function() { array.splice(index, 1); }; + }; + scope.$watch(watchExpr, function ngSwitchWatchAction(value) { var i, ii; - for (i = 0, ii = previousElements.length; i < ii; ++i) { - previousElements[i].remove(); + for (i = 0, ii = previousLeaveAnimations.length; i < ii; ++i) { + $animate.cancel(previousLeaveAnimations[i]); } - previousElements.length = 0; + previousLeaveAnimations.length = 0; for (i = 0, ii = selectedScopes.length; i < ii; ++i) { - var selected = selectedElements[i]; + var selected = getBlockNodes(selectedElements[i].clone); selectedScopes[i].$destroy(); - previousElements[i] = selected; - $animate.leave(selected, function() { - previousElements.splice(i, 1); - }); + var promise = previousLeaveAnimations[i] = $animate.leave(selected); + promise.then(spliceFactory(previousLeaveAnimations, i)); } selectedElements.length = 0; selectedScopes.length = 0; if ((selectedTranscludes = ngSwitchController.cases['!' + value] || ngSwitchController.cases['?'])) { - scope.$eval(attr.change); forEach(selectedTranscludes, function(selectedTransclude) { - var selectedScope = scope.$new(); - selectedScopes.push(selectedScope); - selectedTransclude.transclude(selectedScope, function(caseElement) { + selectedTransclude.transclude(function(caseElement, selectedScope) { + selectedScopes.push(selectedScope); var anchor = selectedTransclude.element; + caseElement[caseElement.length++] = document.createComment(' end ngSwitchWhen: '); + var block = { clone: caseElement }; - selectedElements.push(caseElement); + selectedElements.push(block); $animate.enter(caseElement, anchor.parent(), anchor); }); }); @@ -21021,8 +23576,9 @@ var ngSwitchDirective = ['$animate', function($animate) { var ngSwitchWhenDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attrs, ctrl, $transclude) { ctrl.cases['!' + attrs.ngSwitchWhen] = (ctrl.cases['!' + attrs.ngSwitchWhen] || []); ctrl.cases['!' + attrs.ngSwitchWhen].push({ transclude: $transclude, element: element }); @@ -21031,8 +23587,9 @@ var ngSwitchWhenDirective = ngDirective({ var ngSwitchDefaultDirective = ngDirective({ transclude: 'element', - priority: 800, + priority: 1200, require: '^ngSwitch', + multiElement: true, link: function(scope, element, attr, ctrl, $transclude) { ctrl.cases['?'] = (ctrl.cases['?'] || []); ctrl.cases['?'].push({ transclude: $transclude, element: element }); @@ -21042,7 +23599,7 @@ var ngSwitchDefaultDirective = ngDirective({ /** * @ngdoc directive * @name ngTransclude - * @restrict AC + * @restrict EAC * * @description * Directive that marks the insertion point for the transcluded DOM of the nearest parent directive that uses transclusion. @@ -21063,7 +23620,7 @@ var ngSwitchDefaultDirective = ngDirective({ scope: { title:'@' }, template: '
    ' + '
    {{title}}
    ' + - '
    ' + + '' + '
    ' }; }) @@ -21094,6 +23651,7 @@ var ngSwitchDefaultDirective = ngDirective({ * */ var ngTranscludeDirective = ngDirective({ + restrict: 'EAC', link: function($scope, $element, $attrs, controller, $transclude) { if (!$transclude) { throw minErr('ngTransclude')('orphan', @@ -21274,7 +23832,7 @@ var ngOptionsMinErr = minErr('ngOptions'); Select bogus.

    - Currently selected: {{ {selected_color:myColor} }} + Currently selected: {{ {selected_color:myColor} }}
    @@ -21294,7 +23852,11 @@ var ngOptionsMinErr = minErr('ngOptions'); */ -var ngOptionsDirective = valueFn({ terminal: true }); +var ngOptionsDirective = valueFn({ + restrict: 'A', + terminal: true +}); + // jshint maxlen: false var selectDirective = ['$compile', '$parse', function($compile, $parse) { //000011111111110000000000022222222220000000000000000000003333333333000000000000004444444444444440000000005555555555555550000000666666666666666000000000000000777777777700000000000000000008888888888 @@ -21323,7 +23885,7 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) { }; - self.addOption = function(value) { + self.addOption = function(value, element) { assertNotHasOwnProperty(value, '"option value"'); optionsMap[value] = true; @@ -21331,6 +23893,12 @@ var selectDirective = ['$compile', '$parse', function($compile, $parse) { $element.val(value); if (unknownOption.parent()) unknownOption.remove(); } + // Workaround for https://code.google.com/p/chromium/issues/detail?id=381459 + // Adding an
    - {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} + + {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} + {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} From 81ecaf945d7cbb21547664c4de03747e71c8084b Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 17:37:51 +0200 Subject: [PATCH 073/108] BF: Made /op work when providing no power value. 50 is used as default in this case --- webclient/room/room-controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 9bb0d8e2d4..2af11edf32 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -460,7 +460,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var powerLevel = 50; // default power level for op if (matches) { var user_id = matches[1]; - if (matches.length === 4) { + if (matches.length === 4 && undefined !== matches[3]) { powerLevel = parseInt(matches[3]); } if (powerLevel !== NaN) { From 6d18b52931dd46fb8ba3a680cc9519d4988815af Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 17:40:34 +0200 Subject: [PATCH 074/108] Clean previous request feedback when doing a new request --- webclient/room/room-controller.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 2af11edf32..171d4c0d99 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -523,6 +523,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) } if (promise) { + // Reset previous feedback + $scope.feedback = ""; + promise.then( function() { console.log("Request successfully sent"); From 5a06f5c5fcac58ecd36c0a3c186ed8639767dbe3 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 18:24:03 +0200 Subject: [PATCH 075/108] Reenabled transparent echo message. It turns to opaque without flickering now. --- .../matrix/event-handler-service.js | 34 +++++++++++++++++-- webclient/room/room-controller.js | 25 +++++--------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 38b7bd6b6d..277faa6f77 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -86,8 +86,15 @@ angular.module('eventHandlerService', []) if (isLiveEvent) { if (event.user_id === matrixService.config().user_id && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) { - // assume we've already echoed it - // FIXME: track events by ID and ungrey the right message to show it's been delivered + // Assume we've already echoed it. So, there is a fake event in the messages list of the room + // Replace this fake event by the true one + var index = getRoomEventIndex(event.room_id, event.event_id); + if (index) { + $rootScope.events.rooms[event.room_id].messages[index] = event; + } + else { + $rootScope.events.rooms[event.room_id].messages.push(event); + } } else { $rootScope.events.rooms[event.room_id].messages.push(event); @@ -190,6 +197,29 @@ angular.module('eventHandlerService', []) } }; + /** + * Get the index of the event in $rootScope.events.rooms[room_id].messages + * @param {type} room_id the room id + * @param {type} event_id the event id to look for + * @returns {Number | undefined} the index. undefined if not found. + */ + var getRoomEventIndex = function(room_id, event_id) { + var index; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + for (var i = 0; i < room.messages.length; i++) { + var message = room.messages[i]; + console.log(message.event_id); + if (event_id === message.event_id) { + index = i; + break; + } + } + } + return index; + } + return { ROOM_CREATE_EVENT: ROOM_CREATE_EVENT, MSG_EVENT: MSG_EVENT, diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 171d4c0d99..0000fcfc61 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -513,8 +513,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) room_id: $scope.room_id, type: "m.room.message", user_id: $scope.state.user_id, - // FIXME: re-enable echo_msg_state when we have a nice way to turn the field off again - // echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML + echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML }; $scope.textInput = ""; @@ -527,25 +526,17 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.feedback = ""; promise.then( - function() { + function(response) { console.log("Request successfully sent"); - if (!echo) { - $scope.textInput = ""; - } -/* - if (echoMessage) { - // Remove the fake echo message from the room messages - // It will be replaced by the one acknowledged by the server - // ...except this causes a nasty flicker. So don't swap messages for now. --matthew - // var index = $rootScope.events.rooms[$scope.room_id].messages.indexOf(echoMessage); - // if (index > -1) { - // $rootScope.events.rooms[$scope.room_id].messages.splice(index, 1); - // } + if (echo) { + // Mark this fake message event with its allocated event_id + // When the true message event will come from the events stream (in handleMessage), + // we will be able to replace the fake one by the true one + echoMessage.event_id = response.data.event_id; } else { $scope.textInput = ""; - } -*/ + } }, function(error) { $scope.feedback = "Request failed: " + error.data.error; From 7a153b5c94bbd4f1778a2d32ee5c090967c5cb83 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 10 Sep 2014 18:29:52 +0200 Subject: [PATCH 076/108] Show echoed emote with transparency --- webclient/room/room.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webclient/room/room.html b/webclient/room/room.html index 5debeaba7c..054b876f81 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -93,7 +93,10 @@ - + + From 8dcb6f24b518d668290d6a85493ff4994ddce378 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 09:11:24 +0200 Subject: [PATCH 077/108] getRoomEventIndex: improved speed for what it is used --- webclient/components/matrix/event-handler-service.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 277faa6f77..b19ec27a9d 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -205,12 +205,13 @@ angular.module('eventHandlerService', []) */ var getRoomEventIndex = function(room_id, event_id) { var index; - + var room = $rootScope.events.rooms[room_id]; if (room) { - for (var i = 0; i < room.messages.length; i++) { + // Start looking from the tail since the first goal of this function + // is to find a messaged among the latest ones + for (var i = room.messages.length - 1; i > 0; i--) { var message = room.messages[i]; - console.log(message.event_id); if (event_id === message.event_id) { index = i; break; From 7e7eb0efc14733b576942af4590683c8b749e94e Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 11:31:24 +0200 Subject: [PATCH 078/108] Show room topic change in the chat history and in the recents --- .../matrix/event-handler-service.js | 25 ++++++++++++++++--- webclient/recents/recents-controller.js | 5 ++++ webclient/recents/recents.html | 4 +++ webclient/room/room.html | 5 ++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index b19ec27a9d..002a9fbd5d 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -35,6 +35,7 @@ angular.module('eventHandlerService', []) var POWERLEVEL_EVENT = "POWERLEVEL_EVENT"; var CALL_EVENT = "CALL_EVENT"; var NAME_EVENT = "NAME_EVENT"; + var TOPIC_EVENT = "TOPIC_EVENT"; var initialSyncDeferred = $q.defer(); @@ -170,24 +171,39 @@ angular.module('eventHandlerService', []) }; // TODO: Can this just be a generic "I am a room state event, can haz store?" - var handleRoomTopic = function(event, isLiveEvent) { + var handleRoomTopic = function(event, isLiveEvent, isStateEvent) { console.log("handleRoomTopic live="+isLiveEvent); initRoom(event.room_id); + // Add topic changes as if they were a room message + if (!isStateEvent) { + if (isLiveEvent) { + $rootScope.events.rooms[event.room_id].messages.push(event); + } + else { + $rootScope.events.rooms[event.room_id].messages.unshift(event); + } + } + // live events always update, but non-live events only update if the // ts is later. + var latestData = true; if (!isLiveEvent) { var eventTs = event.ts; var storedEvent = $rootScope.events.rooms[event.room_id][event.type]; if (storedEvent) { if (storedEvent.ts > eventTs) { // ignore it, we have a newer one already. - return; + latestData = false; } } } - $rootScope.events.rooms[event.room_id][event.type] = event; + if (latestData) { + $rootScope.events.rooms[event.room_id][event.type] = event; + } + + $rootScope.$broadcast(TOPIC_EVENT, event, isLiveEvent); }; var handleCallEvent = function(event, isLiveEvent) { @@ -229,6 +245,7 @@ angular.module('eventHandlerService', []) POWERLEVEL_EVENT: POWERLEVEL_EVENT, CALL_EVENT: CALL_EVENT, NAME_EVENT: NAME_EVENT, + TOPIC_EVENT: TOPIC_EVENT, handleEvent: function(event, isLiveEvent, isStateEvent) { // Avoid duplicated events @@ -279,7 +296,7 @@ angular.module('eventHandlerService', []) handleRoomName(event, isLiveEvent); break; case 'm.room.topic': - handleRoomTopic(event, isLiveEvent); + handleRoomTopic(event, isLiveEvent, isStateEvent); break; default: console.log("Unable to handle event type " + event.type); diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 5cf74cad4e..8ce0969164 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -68,6 +68,11 @@ angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHand $rootScope.rooms[event.room_id] = event; } }); + $rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) { + if (isLive) { + $rootScope.rooms[event.room_id].lastMsg = event; + } + }); }; /** diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index d6bea52cbe..6976bab879 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -76,6 +76,10 @@ +
    + {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }} +
    +
    Call diff --git a/webclient/room/room.html b/webclient/room/room.html index 054b876f81..dba6586e00 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -113,6 +113,11 @@ ng-click="$parent.fullScreenImageURL = msg.content.url"/>
    + + + {{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }} + +
    From af44e9556d7f2aab93b96f11eef02d23cb65ac8e Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 11:49:59 +0200 Subject: [PATCH 079/108] BF: made input autofocus work when opening the room topic input --- webclient/room/room-controller.js | 5 +++++ webclient/room/room.html | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 0000fcfc61..94c7688907 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -58,6 +58,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $scope.topic.newTopicText = ""; } + // Force focus to the input + $timeout(function() { + angular.element('.roomTopicInput').focus(); + }, 0); + $scope.topic.isEditing = true; }, updateTopic: function() { diff --git a/webclient/room/room.html b/webclient/room/room.html index dba6586e00..587b0057a6 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -16,8 +16,7 @@ {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}}
    - +
    From 14a9652324a1dbfec55b83ae4fbfb7ee3bf1f3da Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 11:53:37 +0200 Subject: [PATCH 080/108] Room topic: if the request fails, show the error in the feedback --- webclient/room/room-controller.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 94c7688907..fe3821eb45 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -67,7 +67,14 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) }, updateTopic: function() { console.log("Updating topic to "+$scope.topic.newTopicText); - matrixService.setTopic($scope.room_id, $scope.topic.newTopicText); + matrixService.setTopic($scope.room_id, $scope.topic.newTopicText).then( + function() { + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + } + ); + $scope.topic.isEditing = false; }, cancelEdit: function() { From cc049851d0cd76ea5cb92ca2c6f7b6a6c45e217e Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 12:01:44 +0200 Subject: [PATCH 081/108] On member avatar mouseover, show user_id and power level --- webclient/room/room.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/room/room.html b/webclient/room/room.html index 587b0057a6..98423ae40b 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -37,7 +37,7 @@ {{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}
    From c92740e8a905b3f19a27290410823cff9b35b945 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 13:43:55 +0200 Subject: [PATCH 082/108] Enable enter key in the invite input --- webclient/room/room.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/room/room.html b/webclient/room/room.html index 98423ae40b..2225cc7646 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -150,7 +150,7 @@
    Invite a user: - + From 6b20fef52af078e6fa7343f2e9c23fcbbf6fef48 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 13:52:07 +0200 Subject: [PATCH 083/108] Invite: reset the input when the invitation has been done --- webclient/room/room-controller.js | 7 ++++--- webclient/room/room.html | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index fe3821eb45..da77864017 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -694,12 +694,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) ); }; - $scope.inviteUser = function(user_id) { + $scope.inviteUser = function() { - matrixService.invite($scope.room_id, user_id).then( + matrixService.invite($scope.room_id, $scope.userIDToInvite).then( function() { console.log("Invited."); - $scope.feedback = "Invite sent successfully"; + $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite; + $scope.userIDToInvite = ""; }, function(reason) { $scope.feedback = "Failure: " + reason; diff --git a/webclient/room/room.html b/webclient/room/room.html index 2225cc7646..b00d632292 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -150,8 +150,8 @@
    Invite a user: - - + + From aa347b52bace5cfe1ff528ad16973e02ae1b5dc1 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Thu, 11 Sep 2014 15:07:44 +0200 Subject: [PATCH 084/108] Use autofill-event.js to workaround browsers issue: Form model doesn't update on autocomplete https://github.com/angular/angular.js/issues/1460 --- webclient/index.html | 1 + webclient/js/autofill-event.js | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100755 webclient/js/autofill-event.js diff --git a/webclient/index.html b/webclient/index.html index dd2393722c..9eea08215c 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -17,6 +17,7 @@ + diff --git a/webclient/js/autofill-event.js b/webclient/js/autofill-event.js new file mode 100755 index 0000000000..006f83e1be --- /dev/null +++ b/webclient/js/autofill-event.js @@ -0,0 +1,117 @@ +/** + * Autofill event polyfill ##version:1.0.0## + * (c) 2014 Google, Inc. + * License: MIT + */ +(function(window) { + var $ = window.jQuery || window.angular.element; + var rootElement = window.document.documentElement, + $rootElement = $(rootElement); + + addGlobalEventListener('change', markValue); + addValueChangeByJsListener(markValue); + + $.prototype.checkAndTriggerAutoFillEvent = jqCheckAndTriggerAutoFillEvent; + + // Need to use blur and not change event + // as Chrome does not fire change events in all cases an input is changed + // (e.g. when starting to type and then finish the input by auto filling a username) + addGlobalEventListener('blur', function(target) { + // setTimeout needed for Chrome as it fills other + // form fields a little later... + window.setTimeout(function() { + findParentForm(target).find('input').checkAndTriggerAutoFillEvent(); + }, 20); + }); + + window.document.addEventListener('DOMContentLoaded', function() { + // The timeout is needed for Chrome as it auto fills + // login forms some time after DOMContentLoaded! + window.setTimeout(function() { + $rootElement.find('input').checkAndTriggerAutoFillEvent(); + }, 200); + }, false); + + return; + + // ---------- + + function jqCheckAndTriggerAutoFillEvent() { + var i, el; + for (i=0; i 0) { + forEach(this, function(el) { + listener(el, newValue); + }); + } + return res; + } + } + + function addGlobalEventListener(eventName, listener) { + // Use a capturing event listener so that + // we also get the event when it's stopped! + // Also, the blur event does not bubble. + rootElement.addEventListener(eventName, onEvent, true); + + function onEvent(event) { + var target = event.target; + listener(target); + } + } + + function findParentForm(el) { + while (el) { + if (el.nodeName === 'FORM') { + return $(el); + } + el = el.parentNode; + } + return $(); + } + + function forEach(arr, listener) { + if (arr.forEach) { + return arr.forEach(listener); + } + var i; + for (i=0; i Date: Thu, 11 Sep 2014 15:46:24 +0200 Subject: [PATCH 085/108] Added support of copy/paste of multi lines content --- webclient/app.css | 5 +++++ webclient/room/room.html | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/webclient/app.css b/webclient/app.css index 2564ce3286..71af550679 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -257,6 +257,7 @@ a:active { color: #000; } #mainInput { width: 100%; + resize: none; } .blink { @@ -514,6 +515,10 @@ a:active { color: #000; } text-align: left ! important; } +.bubble .message { + /* Break lines when encountering CR+LF */ + white-space: pre; +} .bubble .messagePending { opacity: 0.3 } diff --git a/webclient/room/room.html b/webclient/room/room.html index b00d632292..530545da53 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -97,6 +97,7 @@ ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/> @@ -138,7 +139,7 @@ {{ state.user_id }}
    - +