diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 7d61207554..e4b7cd286f 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -53,7 +53,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
* Open a given page.
* @param {String} url url of the page
*/
- $scope.goToPage = function(url) {
+ $rootScope.goToPage = function(url) {
$location.url(url);
};
diff --git a/webclient/app-directive.js b/webclient/app-directive.js
index 75283598ab..c1ba0af3a9 100644
--- a/webclient/app-directive.js
+++ b/webclient/app-directive.js
@@ -40,4 +40,45 @@ angular.module('matrixWebClient')
}
}
};
-}]);
\ No newline at end of file
+}])
+.directive('asjson', function() {
+ return {
+ restrict: 'A',
+ require: 'ngModel',
+ link: function (scope, element, attrs, ngModelCtrl) {
+ function isValidJson(model) {
+ var flag = true;
+ try {
+ angular.fromJson(model);
+ } catch (err) {
+ flag = false;
+ }
+ return flag;
+ };
+
+ function string2JSON(text) {
+ try {
+ var j = angular.fromJson(text);
+ ngModelCtrl.$setValidity('json', true);
+ return j;
+ } catch (err) {
+ //returning undefined results in a parser error as of angular-1.3-rc.0, and will not go through $validators
+ //return undefined
+ ngModelCtrl.$setValidity('json', false);
+ return text;
+ }
+ };
+
+ function JSON2String(object) {
+ return angular.toJson(object, true);
+ };
+
+ //$validators is an object, where key is the error
+ //ngModelCtrl.$validators.json = isValidJson;
+
+ //array pipelines
+ ngModelCtrl.$parsers.push(string2JSON);
+ ngModelCtrl.$formatters.push(JSON2String);
+ }
+ }
+});
diff --git a/webclient/app-filter.js b/webclient/app-filter.js
index 39ea1d637d..f19db4141d 100644
--- a/webclient/app-filter.js
+++ b/webclient/app-filter.js
@@ -76,6 +76,17 @@ angular.module('matrixWebClient')
return filtered;
};
})
+.filter('stateEventsFilter', function($sce) {
+ return function(events) {
+ var filtered = {};
+ angular.forEach(events, function(value, key) {
+ if (value && typeof(value.state_key) === "string") {
+ filtered[key] = value;
+ }
+ });
+ return filtered;
+ };
+})
.filter('unsafe', ['$sce', function($sce) {
return function(text) {
return $sce.trustAsHtml(text);
diff --git a/webclient/app.css b/webclient/app.css
index bdf475d635..5ab8e2b8fd 100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -403,6 +403,7 @@ textarea, input {
}
.roomNameSection, .roomTopicSection {
+ text-align: right;
float: right;
width: 100%;
}
@@ -412,9 +413,40 @@ textarea, input {
}
.roomHeaderInfo {
+ text-align: right;
float: right;
margin-top: 15px;
- width: 50%;
+}
+
+/*** Room Info Dialog ***/
+
+.room-info {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.room-info-event {
+ border-bottom: 1pt solid black;
+}
+
+.room-info-event-meta {
+ padding-top: 1em;
+ padding-bottom: 1em;
+}
+
+.room-info-event-content {
+ padding-top: 1em;
+ padding-bottom: 1em;
+}
+
+.monospace {
+ font-family: monospace;
+}
+
+.room-info-textarea-content {
+ height: auto;
+ width: 100%;
+ resize: vertical;
}
/*** Participant list ***/
diff --git a/webclient/app.js b/webclient/app.js
index 099e2170a0..c091f8c6cf 100644
--- a/webclient/app.js
+++ b/webclient/app.js
@@ -30,8 +30,10 @@ var matrixWebClient = angular.module('matrixWebClient', [
'MatrixCall',
'eventStreamService',
'eventHandlerService',
+ 'notificationService',
'infinite-scroll',
- 'ui.bootstrap'
+ 'ui.bootstrap',
+ 'monospaced.elastic'
]);
matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index e7109c0cb4..e63584510b 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -27,8 +27,8 @@ Typically, this service will store events or broadcast them to any listeners
if typically all the $on method would do is update its own $scope.
*/
angular.module('eventHandlerService', [])
-.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence',
-function(matrixService, $rootScope, $q, $timeout, mPresence) {
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', 'notificationService',
+function(matrixService, $rootScope, $q, $timeout, mPresence, notificationService) {
var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
var MSG_EVENT = "MSG_EVENT";
var MEMBER_EVENT = "MEMBER_EVENT";
@@ -45,44 +45,6 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
var eventMap = {};
$rootScope.presence = {};
-
- // TODO: This is attached to the rootScope so .html can just go containsBingWord
- // for determining classes so it is easy to highlight bing messages. It seems a
- // bit strange to put the impl in this service though, but I can't think of a better
- // file to put it in.
- $rootScope.containsBingWord = function(content) {
- if (!content || $.type(content) != "string") {
- return false;
- }
- var bingWords = matrixService.config().bingWords;
- var shouldBing = false;
-
- // case-insensitive name check for user_id OR display_name if they exist
- var myUserId = matrixService.config().user_id;
- if (myUserId) {
- myUserId = myUserId.toLocaleLowerCase();
- }
- var myDisplayName = matrixService.config().display_name;
- if (myDisplayName) {
- myDisplayName = myDisplayName.toLocaleLowerCase();
- }
- if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
- (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
- shouldBing = true;
- }
-
- // bing word list check
- if (bingWords && !shouldBing) {
- for (var i=0; i<bingWords.length; i++) {
- var re = RegExp(bingWords[i]);
- if (content.search(re) != -1) {
- shouldBing = true;
- break;
- }
- }
- }
- return shouldBing;
- };
var initialSyncDeferred;
@@ -172,6 +134,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
};
var handleMessage = function(event, isLiveEvent) {
+ // Check for empty event content
+ var hasContent = false;
+ for (var prop in event.content) {
+ hasContent = true;
+ break;
+ }
+ if (!hasContent) {
+ // empty json object is a redacted event, so ignore.
+ return;
+ }
+
if (isLiveEvent) {
if (event.user_id === matrixService.config().user_id &&
(event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) {
@@ -190,7 +163,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
}
if (window.Notification && event.user_id != matrixService.config().user_id) {
- var shouldBing = $rootScope.containsBingWord(event.content.body);
+ var shouldBing = notificationService.containsBingWord(
+ matrixService.config().user_id,
+ matrixService.config().display_name,
+ matrixService.config().bingWords,
+ event.content.body
+ );
// Ideally we would notify only when the window is hidden (i.e. document.hidden = true).
//
@@ -220,17 +198,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
if (event.content.msgtype === "m.emote") {
message = "* " + displayname + " " + message;
}
+ else if (event.content.msgtype === "m.image") {
+ message = displayname + " sent an image.";
+ }
+
+ var roomTitle = matrixService.getRoomIdToAliasMapping(event.room_id);
+ var theRoom = $rootScope.events.rooms[event.room_id];
+ if (!roomTitle && theRoom && theRoom["m.room.name"] && theRoom["m.room.name"].content) {
+ roomTitle = theRoom["m.room.name"].content.name;
+ }
- var notification = new window.Notification(
- displayname +
- " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
- {
- "body": message,
- "icon": member ? member.avatar_url : undefined
- });
- $timeout(function() {
- notification.close();
- }, 5 * 1000);
+ if (!roomTitle) {
+ roomTitle = event.room_id;
+ }
+
+ notificationService.showNotification(
+ displayname + " (" + roomTitle + ")",
+ message,
+ member ? member.avatar_url : undefined,
+ function() {
+ console.log("notification.onclick() room=" + event.room_id);
+ $rootScope.goToPage('room/' + event.room_id);
+ }
+ );
}
}
}
@@ -319,6 +309,31 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
};
+
+ var handleRedaction = function(event, isLiveEvent) {
+ if (!isLiveEvent) {
+ // we have nothing to remove, so just ignore it.
+ console.log("Received redacted event: "+JSON.stringify(event));
+ return;
+ }
+
+ // we need to remove something possibly: do we know the redacted
+ // event ID?
+ if (eventMap[event.redacts]) {
+ // remove event from list of messages in this room.
+ var eventList = $rootScope.events.rooms[event.room_id].messages;
+ for (var i=0; i<eventList.length; i++) {
+ if (eventList[i].event_id === event.redacts) {
+ console.log("Removing event " + event.redacts);
+ eventList.splice(i, 1);
+ break;
+ }
+ }
+
+ // broadcast the redaction so controllers can nuke this
+ console.log("Redacted an event.");
+ }
+ }
/**
* Get the index of the event in $rootScope.events.rooms[room_id].messages
@@ -481,7 +496,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
case 'm.room.topic':
handleRoomTopic(event, isLiveEvent, isStateEvent);
break;
+ case 'm.room.redaction':
+ handleRedaction(event, isLiveEvent);
+ break;
default:
+ // if it is a state event, then just add it in so it
+ // displays on the Room Info screen.
+ if (typeof(event.state_key) === "string") { // incls. 0-len strings
+ if (event.room_id) {
+ handleRoomDateEvent(event, isLiveEvent, false);
+ }
+ }
console.log("Unable to handle event type " + event.type);
console.log(JSON.stringify(event, undefined, 4));
break;
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index e6f2acc5fd..3d64a569a1 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -47,7 +47,6 @@ angular.module('matrixFilter', [])
else if (room.members && !isPublicRoom) { // Do not rename public room
var user_id = matrixService.config().user_id;
-
// Else, build the name from its users
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
@@ -65,8 +64,16 @@ angular.module('matrixFilter', [])
var otherUserId;
- if (Object.keys(room.members)[0] && Object.keys(room.members)[0] !== user_id) {
+ if (Object.keys(room.members)[0]) {
otherUserId = Object.keys(room.members)[0];
+ // this could be an invite event (from event stream)
+ if (otherUserId === user_id &&
+ room.members[user_id].content.membership === "invite") {
+ // this is us being invited to this room, so the
+ // *user_id* is the other user ID and not the state
+ // key.
+ otherUserId = room.members[user_id].user_id;
+ }
}
else {
// it's got to be an invite, or failing that a self-chat;
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index a4f0568bce..1840cf46c0 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -438,6 +438,14 @@ angular.module('matrixService', [])
return this.sendMessage(room_id, msg_id, content);
},
+ redactEvent: function(room_id, event_id) {
+ var path = "/rooms/$room_id/redact/$event_id";
+ path = path.replace("$room_id", room_id);
+ path = path.replace("$event_id", event_id);
+ var content = {};
+ return doRequest("POST", path, undefined, content);
+ },
+
// get a snapshot of the members in a room.
getMemberList: function(room_id) {
// Like the cmd client, escape room ids
diff --git a/webclient/components/matrix/notification-service.js b/webclient/components/matrix/notification-service.js
new file mode 100644
index 0000000000..9a911413c3
--- /dev/null
+++ b/webclient/components/matrix/notification-service.js
@@ -0,0 +1,104 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service manages notifications: enabling, creating and showing them. This
+also contains 'bing word' logic.
+*/
+angular.module('notificationService', [])
+.factory('notificationService', ['$timeout', function($timeout) {
+
+ var getLocalPartFromUserId = function(user_id) {
+ if (!user_id) {
+ return null;
+ }
+ var localpartRegex = /@(.*):\w+/i
+ var results = localpartRegex.exec(user_id);
+ if (results && results.length == 2) {
+ return results[1];
+ }
+ return null;
+ };
+
+ return {
+
+ containsBingWord: function(userId, displayName, bingWords, content) {
+ // case-insensitive name check for user_id OR display_name if they exist
+ var userRegex = "";
+ if (userId) {
+ var localpart = getLocalPartFromUserId(userId);
+ if (localpart) {
+ localpart = localpart.toLocaleLowerCase();
+ userRegex += "\\b" + localpart + "\\b";
+ }
+ }
+ if (displayName) {
+ displayName = displayName.toLocaleLowerCase();
+ if (userRegex.length > 0) {
+ userRegex += "|";
+ }
+ userRegex += "\\b" + displayName + "\\b";
+ }
+
+ var regexList = [new RegExp(userRegex, 'i')];
+
+ // bing word list check
+ if (bingWords && bingWords.length > 0) {
+ for (var i=0; i<bingWords.length; i++) {
+ var re = RegExp(bingWords[i], 'i');
+ regexList.push(re);
+ }
+ }
+ return this.hasMatch(regexList, content);
+ },
+
+ hasMatch: function(regExps, content) {
+ if (!content || $.type(content) != "string") {
+ return false;
+ }
+
+ if (regExps && regExps.length > 0) {
+ for (var i=0; i<regExps.length; i++) {
+ if (content.search(regExps[i]) != -1) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ showNotification: function(title, body, icon, onclick) {
+ var notification = new window.Notification(
+ title,
+ {
+ "body": body,
+ "icon": icon
+ }
+ );
+
+ if (onclick) {
+ notification.onclick = onclick;
+ }
+
+ $timeout(function() {
+ notification.close();
+ }, 5 * 1000);
+ }
+ };
+
+}]);
diff --git a/webclient/index.html b/webclient/index.html
index 35c8051298..bc011a6c72 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -20,6 +20,7 @@
<script type='text/javascript' src="js/ui-bootstrap-tpls-0.11.2.js"></script>
<script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
<script type='text/javascript' src='js/autofill-event.js'></script>
+ <script type='text/javascript' src='js/elastic.js'></script>
<script src="app.js"></script>
<script src="config.js"></script>
<script src="app-controller.js"></script>
@@ -40,6 +41,7 @@
<script src="components/matrix/matrix-phone-service.js"></script>
<script src="components/matrix/event-stream-service.js"></script>
<script src="components/matrix/event-handler-service.js"></script>
+ <script src="components/matrix/notification-service.js"></script>
<script src="components/matrix/presence-service.js"></script>
<script src="components/fileInput/file-input-directive.js"></script>
<script src="components/fileUpload/file-upload-service.js"></script>
diff --git a/webclient/js/elastic.js b/webclient/js/elastic.js
new file mode 100644
index 0000000000..d585d81109
--- /dev/null
+++ b/webclient/js/elastic.js
@@ -0,0 +1,216 @@
+/*
+ * angular-elastic v2.4.0
+ * (c) 2014 Monospaced http://monospaced.com
+ * License: MIT
+ */
+
+angular.module('monospaced.elastic', [])
+
+ .constant('msdElasticConfig', {
+ append: ''
+ })
+
+ .directive('msdElastic', [
+ '$timeout', '$window', 'msdElasticConfig',
+ function($timeout, $window, config) {
+ 'use strict';
+
+ return {
+ require: 'ngModel',
+ restrict: 'A, C',
+ link: function(scope, element, attrs, ngModel) {
+
+ // cache a reference to the DOM element
+ var ta = element[0],
+ $ta = element;
+
+ // ensure the element is a textarea, and browser is capable
+ if (ta.nodeName !== 'TEXTAREA' || !$window.getComputedStyle) {
+ return;
+ }
+
+ // set these properties before measuring dimensions
+ $ta.css({
+ 'overflow': 'hidden',
+ 'overflow-y': 'hidden',
+ 'word-wrap': 'break-word'
+ });
+
+ // force text reflow
+ var text = ta.value;
+ ta.value = '';
+ ta.value = text;
+
+ var append = attrs.msdElastic ? attrs.msdElastic.replace(/\\n/g, '\n') : config.append,
+ $win = angular.element($window),
+ mirrorInitStyle = 'position: absolute; top: -999px; right: auto; bottom: auto;' +
+ 'left: 0; overflow: hidden; -webkit-box-sizing: content-box;' +
+ '-moz-box-sizing: content-box; box-sizing: content-box;' +
+ 'min-height: 0 !important; height: 0 !important; padding: 0;' +
+ 'word-wrap: break-word; border: 0;',
+ $mirror = angular.element('<textarea tabindex="-1" ' +
+ 'style="' + mirrorInitStyle + '"/>').data('elastic', true),
+ mirror = $mirror[0],
+ taStyle = getComputedStyle(ta),
+ resize = taStyle.getPropertyValue('resize'),
+ borderBox = taStyle.getPropertyValue('box-sizing') === 'border-box' ||
+ taStyle.getPropertyValue('-moz-box-sizing') === 'border-box' ||
+ taStyle.getPropertyValue('-webkit-box-sizing') === 'border-box',
+ boxOuter = !borderBox ? {width: 0, height: 0} : {
+ width: parseInt(taStyle.getPropertyValue('border-right-width'), 10) +
+ parseInt(taStyle.getPropertyValue('padding-right'), 10) +
+ parseInt(taStyle.getPropertyValue('padding-left'), 10) +
+ parseInt(taStyle.getPropertyValue('border-left-width'), 10),
+ height: parseInt(taStyle.getPropertyValue('border-top-width'), 10) +
+ parseInt(taStyle.getPropertyValue('padding-top'), 10) +
+ parseInt(taStyle.getPropertyValue('padding-bottom'), 10) +
+ parseInt(taStyle.getPropertyValue('border-bottom-width'), 10)
+ },
+ minHeightValue = parseInt(taStyle.getPropertyValue('min-height'), 10),
+ heightValue = parseInt(taStyle.getPropertyValue('height'), 10),
+ minHeight = Math.max(minHeightValue, heightValue) - boxOuter.height,
+ maxHeight = parseInt(taStyle.getPropertyValue('max-height'), 10),
+ mirrored,
+ active,
+ copyStyle = ['font-family',
+ 'font-size',
+ 'font-weight',
+ 'font-style',
+ 'letter-spacing',
+ 'line-height',
+ 'text-transform',
+ 'word-spacing',
+ 'text-indent'];
+
+ // exit if elastic already applied (or is the mirror element)
+ if ($ta.data('elastic')) {
+ return;
+ }
+
+ // Opera returns max-height of -1 if not set
+ maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
+
+ // append mirror to the DOM
+ if (mirror.parentNode !== document.body) {
+ angular.element(document.body).append(mirror);
+ }
+
+ // set resize and apply elastic
+ $ta.css({
+ 'resize': (resize === 'none' || resize === 'vertical') ? 'none' : 'horizontal'
+ }).data('elastic', true);
+
+ /*
+ * methods
+ */
+
+ function initMirror() {
+ var mirrorStyle = mirrorInitStyle;
+
+ mirrored = ta;
+ // copy the essential styles from the textarea to the mirror
+ taStyle = getComputedStyle(ta);
+ angular.forEach(copyStyle, function(val) {
+ mirrorStyle += val + ':' + taStyle.getPropertyValue(val) + ';';
+ });
+ mirror.setAttribute('style', mirrorStyle);
+ }
+
+ function adjust() {
+ var taHeight,
+ taComputedStyleWidth,
+ mirrorHeight,
+ width,
+ overflow;
+
+ if (mirrored !== ta) {
+ initMirror();
+ }
+
+ // active flag prevents actions in function from calling adjust again
+ if (!active) {
+ active = true;
+
+ mirror.value = ta.value + append; // optional whitespace to improve animation
+ mirror.style.overflowY = ta.style.overflowY;
+
+ taHeight = ta.style.height === '' ? 'auto' : parseInt(ta.style.height, 10);
+
+ taComputedStyleWidth = getComputedStyle(ta).getPropertyValue('width');
+
+ // ensure getComputedStyle has returned a readable 'used value' pixel width
+ if (taComputedStyleWidth.substr(taComputedStyleWidth.length - 2, 2) === 'px') {
+ // update mirror width in case the textarea width has changed
+ width = parseInt(taComputedStyleWidth, 10) - boxOuter.width;
+ mirror.style.width = width + 'px';
+ }
+
+ mirrorHeight = mirror.scrollHeight;
+
+ if (mirrorHeight > maxHeight) {
+ mirrorHeight = maxHeight;
+ overflow = 'scroll';
+ } else if (mirrorHeight < minHeight) {
+ mirrorHeight = minHeight;
+ }
+ mirrorHeight += boxOuter.height;
+
+ ta.style.overflowY = overflow || 'hidden';
+
+ if (taHeight !== mirrorHeight) {
+ ta.style.height = mirrorHeight + 'px';
+ scope.$emit('elastic:resize', $ta);
+ }
+
+ // small delay to prevent an infinite loop
+ $timeout(function() {
+ active = false;
+ }, 1);
+
+ }
+ }
+
+ function forceAdjust() {
+ active = false;
+ adjust();
+ }
+
+ /*
+ * initialise
+ */
+
+ // listen
+ if ('onpropertychange' in ta && 'oninput' in ta) {
+ // IE9
+ ta['oninput'] = ta.onkeyup = adjust;
+ } else {
+ ta['oninput'] = adjust;
+ }
+
+ $win.bind('resize', forceAdjust);
+
+ scope.$watch(function() {
+ return ngModel.$modelValue;
+ }, function(newValue) {
+ forceAdjust();
+ });
+
+ scope.$on('elastic:adjust', function() {
+ initMirror();
+ forceAdjust();
+ });
+
+ $timeout(adjust);
+
+ /*
+ * destroy
+ */
+
+ scope.$on('$destroy', function() {
+ $mirror.remove();
+ $win.unbind('resize', forceAdjust);
+ });
+ }
+ };
+ }
+ ]);
diff --git a/webclient/mobile.css b/webclient/mobile.css
index 7c62a072d5..6fa9221ccf 100644
--- a/webclient/mobile.css
+++ b/webclient/mobile.css
@@ -65,13 +65,16 @@
}
#roomName {
- float: left;
- font-size: 14px ! important;
+ font-size: 12px ! important;
margin-top: 0px ! important;
}
+
+ .roomTopicSection {
+ display: none;
+ }
#roomPage {
- top: 35px ! important;
+ top: 40px ! important;
left: 5px ! important;
right: 5px ! important;
bottom: 70px ! important;
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 78520a829d..486ead0da9 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -15,11 +15,21 @@ limitations under the License.
*/
angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
-.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
- function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
+.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'notificationService',
+ function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, notificationService) {
'use strict';
var MESSAGES_PER_PAGINATION = 30;
var THUMBNAIL_SIZE = 320;
+
+ // .html needs this
+ $scope.containsBingWord = function(content) {
+ return notificationService.containsBingWord(
+ matrixService.config().user_id,
+ matrixService.config().display_name,
+ matrixService.config().bingWords,
+ content
+ );
+ };
// Room ids. Computed and resolved in onInit
$scope.room_id = undefined;
@@ -133,7 +143,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Do not autoscroll to the bottom to display the new event if the user is not at the bottom.
// Exception: in case where the event is from the user, we want to force scroll to the bottom
var objDiv = document.getElementById("messageTableWrapper");
- if ((objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) {
+ // add a 10px buffer to this check so if the message list is not *quite*
+ // at the bottom it still scrolls since it basically is at the bottom.
+ if ((10 + objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) {
$timeout(function() {
objDiv.scrollTop = objDiv.scrollHeight;
@@ -189,16 +201,20 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Notify when a user joins
if ((document.hidden || matrixService.presence.unavailable === mPresence.getState())
&& event.state_key !== $scope.state.user_id && "join" === event.membership) {
- var notification = new window.Notification(
- event.content.displayname +
- " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
- {
- "body": event.content.displayname + " joined",
- "icon": event.content.avatar_url ? event.content.avatar_url : undefined
- });
- $timeout(function() {
- notification.close();
- }, 5 * 1000);
+ var userName = event.content.displayname;
+ if (!userName) {
+ userName = event.state_key;
+ }
+ notificationService.showNotification(
+ userName +
+ " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")",
+ userName + " joined",
+ event.content.avatar_url ? event.content.avatar_url : undefined,
+ function() {
+ console.log("notification.onclick() room=" + event.room_id);
+ $rootScope.goToPage('room/' + event.room_id);
+ }
+ );
}
}
}
@@ -983,10 +999,87 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
};
$scope.openJson = function(content) {
- console.log("Displaying modal dialog for " + JSON.stringify(content));
+ $scope.event_selected = content;
+ // scope this so the template can check power levels and enable/disable
+ // buttons
+ $scope.pow = matrixService.getUserPowerLevel;
+
+ var modalInstance = $modal.open({
+ templateUrl: 'eventInfoTemplate.html',
+ controller: 'EventInfoController',
+ scope: $scope
+ });
+
+ modalInstance.result.then(function(action) {
+ if (action === "redact") {
+ var eventId = $scope.event_selected.event_id;
+ console.log("Redacting event ID " + eventId);
+ matrixService.redactEvent(
+ $scope.event_selected.room_id,
+ eventId
+ ).then(function(response) {
+ console.log("Redaction = " + JSON.stringify(response));
+ }, function(error) {
+ console.error("Failed to redact event: "+JSON.stringify(error));
+ if (error.data.error) {
+ $scope.feedback = error.data.error;
+ }
+ });
+ }
+ }, function() {
+ // any dismiss code
+ });
+ };
+
+ $scope.openRoomInfo = function() {
+ $scope.roomInfo = {};
+ $scope.roomInfo.newEvent = {
+ content: {},
+ type: "",
+ state_key: ""
+ };
+
+ var stateFilter = $filter("stateEventsFilter");
+ var stateEvents = stateFilter($scope.events.rooms[$scope.room_id]);
+ // The modal dialog will 2-way bind this field, so we MUST make a deep
+ // copy of the state events else we will be *actually adjusing our view
+ // of the world* when fiddling with the JSON!! Apparently parse/stringify
+ // is faster than jQuery's extend when doing deep copies.
+ $scope.roomInfo.stateEvents = JSON.parse(JSON.stringify(stateEvents));
var modalInstance = $modal.open({
- template: "<pre>" + angular.toJson(content, true) + "</pre>"
+ templateUrl: 'roomInfoTemplate.html',
+ controller: 'RoomInfoController',
+ size: 'lg',
+ scope: $scope
});
};
-}]);
+}])
+.controller('EventInfoController', function($scope, $modalInstance) {
+ console.log("Displaying modal dialog for >>>> " + JSON.stringify($scope.event_selected));
+ $scope.redact = function() {
+ console.log("User level = "+$scope.pow($scope.room_id, $scope.state.user_id)+
+ " Redact level = "+$scope.events.rooms[$scope.room_id]["m.room.ops_levels"].content.redact_level);
+ console.log("Redact event >> " + JSON.stringify($scope.event_selected));
+ $modalInstance.close("redact");
+ };
+})
+.controller('RoomInfoController', function($scope, $modalInstance, $filter, matrixService) {
+ console.log("Displaying room info.");
+
+ $scope.submit = function(event) {
+ if (event.content) {
+ console.log("submit >>> " + JSON.stringify(event.content));
+ matrixService.sendStateEvent($scope.room_id, event.type,
+ event.content, event.state_key).then(function(response) {
+ $modalInstance.dismiss();
+ }, function(err) {
+ $scope.feedback = err.data.error;
+ }
+ );
+ }
+ };
+
+ $scope.dismiss = $modalInstance.dismiss;
+
+});
diff --git a/webclient/room/room.html b/webclient/room/room.html
index e753b037fe..5265f42dd8 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -1,5 +1,59 @@
<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;">
+ <script type="text/ng-template" id="eventInfoTemplate.html">
+ <div class="modal-body">
+ <pre> {{event_selected | json}} </pre>
+ </div>
+ <div class="modal-footer">
+ <button ng-click="redact()" type="button" class="btn btn-danger"
+ ng-disabled="!events.rooms[room_id]['m.room.ops_levels'].content.redact_level || !pow(room_id, state.user_id) || pow(room_id, state.user_id) < events.rooms[room_id]['m.room.ops_levels'].content.redact_level"
+ title="Delete this event on all home servers. This cannot be undone.">
+ Redact
+ </button>
+ </div>
+ </script>
+
+ <script type="text/ng-template" id="roomInfoTemplate.html">
+ <div class="modal-body">
+ <table class="room-info">
+ <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event">
+ <td class="room-info-event-meta" width="30%">
+ <span class="monospace">{{ key }}</span>
+ <br/>
+ {{ (event.origin_server_ts) | date:'MMM d HH:mm' }}
+ <br/>
+ Set by: <span class="monospace">{{ event.user_id }}</span>
+ <br/>
+ <span ng-show="event.required_power_level >= 0">Required power level: {{event.required_power_level}}<br/></span>
+ <button ng-click="submit(event)" type="button" class="btn btn-success" ng-disabled="!event.content">
+ Submit
+ </button>
+ </td>
+ <td class="room-info-event-content" width="70%">
+ <textarea class="room-info-textarea-content" msd-elastic ng-model="event.content" asjson></textarea>
+ </td>
+ </tr>
+ <tr>
+ <td class="room-info-event-meta" width="30%">
+ <input ng-model="roomInfo.newEvent.type" placeholder="your.event.type" />
+ <br/>
+ <button ng-click="submit(roomInfo.newEvent)" type="button" class="btn btn-success" ng-disabled="!roomInfo.newEvent.content || !roomInfo.newEvent.type">
+ Submit
+ </button>
+ </td>
+ <td class="room-info-event-content" width="70%">
+ <textarea class="room-info-textarea-content" msd-elastic ng-model="roomInfo.newEvent.content" asjson></textarea>
+ </td>
+ </tr>
+ </table>
+ </div>
+ <div class="modal-footer">
+ <button ng-click="dismiss()" type="button" class="btn">
+ Close
+ </button>
+ </div>
+ </script>
+
<div id="roomHeader">
<a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
<div class="roomHeaderInfo">
@@ -79,15 +133,15 @@
</div>
</td>
<td class="avatar">
- <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
+ <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}"
ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
</td>
<td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
- <div class="bubble">
- <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'" ng-click="openJson(msg)">
+ <div class="bubble" ng-click="openJson(msg)">
+ <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
{{ members[msg.state_key].displayname || msg.state_key }} joined
</span>
- <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'" ng-click="openJson(msg)">
+ <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'">
<span ng-if="msg.user_id === msg.state_key">
{{ members[msg.state_key].displayname || msg.state_key }} left
</span>
@@ -101,7 +155,7 @@
</span>
</span>
<span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' ||
- 'ban' === msg.content.membership && msg.changedKey === 'membership'" ng-click="openJson(msg)">
+ 'ban' === msg.content.membership && msg.changedKey === 'membership'">
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
{{ members[msg.state_key].displayname || msg.state_key }}
@@ -109,25 +163,24 @@
: {{ msg.content.reason }}
</span>
</span>
- <span ng-if="msg.changedKey === 'displayname'" ng-click="openJson(msg)">
+ <span ng-if="msg.changedKey === 'displayname'">
{{ msg.user_id }} changed their display name from {{ msg.prev_content.displayname }} to {{ msg.content.displayname }}
</span>
<span ng-show='msg.content.msgtype === "m.emote"'
ng-class="msg.echo_msg_state"
ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"
- ng-click="openJson(msg)"/>
+ />
<span ng-show='msg.content.msgtype === "m.text"'
class="message"
- ng-click="openJson(msg)"
ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
ng-bind-html="(msg.content.msgtype === 'm.text' && msg.type === 'm.room.message' && msg.content.format === 'org.matrix.custom.html') ?
(msg.content.formatted_body | unsanitizedLinky) :
(msg.content.msgtype === 'm.text' && msg.type === 'm.room.message') ? (msg.content.body | linky:'_blank') : '' "/>
- <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id' ng-click="openJson(msg)">Outgoing Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
- <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id' ng-click="openJson(msg)">Incoming Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
+ <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
+ <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
<div ng-show='msg.content.msgtype === "m.image"'>
<div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
@@ -135,15 +188,15 @@
</div>
<div ng-show='msg.content.thumbnail_url' ng-style="{ 'height' : msg.content.thumbnail_info.h }">
<img class="image mouse-pointer" ng-src="{{ msg.content.thumbnail_url }}"
- ng-click="$parent.fullScreenImageURL = msg.content.url"/>
+ ng-click="$parent.fullScreenImageURL = msg.content.url; $event.stopPropagation();"/>
</div>
</div>
- <span ng-if="'m.room.topic' === msg.type" ng-click="openJson(msg)">
+ <span ng-if="'m.room.topic' === msg.type">
{{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }}
</span>
- <span ng-if="'m.room.name' === msg.type" ng-click="openJson(msg)">
+ <span ng-if="'m.room.name' === msg.type">
{{ members[msg.user_id].displayname || msg.user_id }} changed the room name to: {{ msg.content.name }}
</span>
@@ -204,6 +257,9 @@
>
Video Call
</button>
+ <button ng-click="openRoomInfo()">
+ Room Info
+ </button>
</div>
{{ feedback }}
|