summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--CHANGES.rst31
-rw-r--r--UPGRADE.rst10
-rw-r--r--VERSION2
-rw-r--r--docs/freenode.txt1
-rw-r--r--synapse/__init__.py2
-rw-r--r--synapse/handlers/message.py5
-rw-r--r--synapse/handlers/profile.py46
-rw-r--r--tests/handlers/test_presencelike.py11
-rw-r--r--tests/handlers/test_profile.py1
-rwxr-xr-xwebclient/app.css4
-rw-r--r--webclient/components/matrix/event-handler-service.js30
-rw-r--r--webclient/components/matrix/event-stream-service.js2
-rw-r--r--webclient/components/matrix/matrix-call.js2
-rw-r--r--webclient/components/matrix/matrix-filter.js110
-rw-r--r--webclient/home/home-controller.js2
-rw-r--r--webclient/recents/recents-filter.js5
-rw-r--r--webclient/recents/recents.html2
-rw-r--r--webclient/room/room-controller.js109
-rw-r--r--webclient/room/room.html12
19 files changed, 307 insertions, 80 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index 0853c0312c..4e536bc4de 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,5 +1,32 @@
-Latest
-======
+Changes in synapse 0.3.0 (2014-09-18)
+=====================================
+See UPGRADE for information about changes to the client server API, including
+breaking backwards compatibility with VoIP calls and registration API.
+
+Homeserver:
+ * When a user changes their displayname or avatar the server will now update 
+   all their join states to reflect this.
+ * The server now adds "age" key to events to indicate how old they are. This
+   is clock independent, so at no point does any server or webclient have to
+   assume their clock is in sync with everyone else.
+ * Fix bug where we didn't correctly pull in missing PDUs.
+ * Fix bug where prev_content key wasn't always returned.
+ * Add support for password resets.
+
+Webclient:
+ * Improve page content loading.
+ * Join/parts now trigger desktop notifications.
+ * Always show room aliases in the UI if one is present.
+ * No longer show user-count in the recents side panel.
+ * Add up & down arrow support to the text box for message sending to step
+   through your sent history.
+ * Don't display notifications for our own messages.
+ * Emotes are now formatted correctly in desktop notifications.
+ * The recents list now differentiates between public & private rooms.
+ * Fix bug where when switching between rooms the pagination flickered before
+   the view jumped to the bottom of the screen.
+ * Add support for password resets.
+ * Add bing word support.
 
 Registration API:
  * The registration API has been overhauled to function like the login API. In
diff --git a/UPGRADE.rst b/UPGRADE.rst
index 44c0af7282..713fb9ae83 100644
--- a/UPGRADE.rst
+++ b/UPGRADE.rst
@@ -1,4 +1,4 @@
-Upgrading to Latest
+Upgrading to v0.3.0
 ===================
 
 This registration API now closely matches the login API. This introduces a bit
@@ -20,6 +20,14 @@ to the next stage. There is a new login type: ``m.login.email.identity`` which
 contains the ``threepidCreds`` key which were previously sent in the original
 register request. For more information on this, see the specification.
 
+Web Client
+----------
+
+The VoIP specification has changed between v0.2.0 and v0.3.0. Users should
+refresh any browser tabs to get the latest web client code. Users on
+v0.2.0 of the web client will not be able to call those on v0.3.0 and
+vice versa.
+
 
 Upgrading to v0.2.0
 ===================
diff --git a/VERSION b/VERSION
index 7179039691..0d91a54c7d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.3
+0.3.0
diff --git a/docs/freenode.txt b/docs/freenode.txt
new file mode 100644
index 0000000000..84fdf6d523
--- /dev/null
+++ b/docs/freenode.txt
@@ -0,0 +1 @@
+NCjcRSEG
diff --git a/synapse/__init__.py b/synapse/__init__.py
index d60267ebe4..8ef176ea6f 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.3"
+__version__ = "0.3.0"
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index b63863e5b2..14fae689f2 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -268,6 +268,9 @@ class MessageHandler(BaseHandler):
             user, pagination_config, None
         )
 
+        public_rooms = yield self.store.get_rooms(is_public=True)
+        public_room_ids = [r["room_id"] for r in public_rooms]
+
         limit = pagin_config.limit
         if not limit:
             limit = 10
@@ -276,6 +279,8 @@ class MessageHandler(BaseHandler):
             d = {
                 "room_id": event.room_id,
                 "membership": event.membership,
+                "visibility": ("public" if event.room_id in
+                              public_room_ids else "private"),
             }
 
             if event.membership == Membership.INVITE:
diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py
index 023d8c0cf2..dab9b03f04 100644
--- a/synapse/handlers/profile.py
+++ b/synapse/handlers/profile.py
@@ -15,9 +15,9 @@
 
 from twisted.internet import defer
 
-from synapse.api.errors import SynapseError, AuthError
-
-from synapse.api.errors import CodeMessageException
+from synapse.api.errors import SynapseError, AuthError, CodeMessageException
+from synapse.api.constants import Membership
+from synapse.api.events.room import RoomMemberEvent
 
 from ._base import BaseHandler
 
@@ -97,6 +97,8 @@ class ProfileHandler(BaseHandler):
             }
         )
 
+        yield self._update_join_states(target_user)
+
     @defer.inlineCallbacks
     def get_avatar_url(self, target_user):
         if target_user.is_mine:
@@ -144,6 +146,8 @@ class ProfileHandler(BaseHandler):
             }
         )
 
+        yield self._update_join_states(target_user)
+
     @defer.inlineCallbacks
     def collect_presencelike_data(self, user, state):
         if not user.is_mine:
@@ -180,3 +184,39 @@ class ProfileHandler(BaseHandler):
             )
 
         defer.returnValue(response)
+
+    @defer.inlineCallbacks
+    def _update_join_states(self, user):
+        if not user.is_mine:
+            return
+
+        joins = yield self.store.get_rooms_for_user_where_membership_is(
+            user.to_string(),
+            [Membership.JOIN],
+        )
+
+        for j in joins:
+            snapshot = yield self.store.snapshot_room(
+                j.room_id, j.state_key, RoomMemberEvent.TYPE,
+                j.state_key
+            )
+
+            content = {
+                "membership": j.content["membership"],
+                "prev": j.content["membership"],
+            }
+
+            yield self.distributor.fire(
+                "collect_presencelike_data", user, content
+            )
+
+            new_event = self.event_factory.create_event(
+                etype=j.type,
+                room_id=j.room_id,
+                state_key=j.state_key,
+                content=content,
+                user_id=j.state_key,
+            )
+
+            yield self.state_handler.handle_new_event(new_event, snapshot)
+            yield self._on_new_room_event(new_event, snapshot)
diff --git a/tests/handlers/test_presencelike.py b/tests/handlers/test_presencelike.py
index 72c55b3667..047752ad68 100644
--- a/tests/handlers/test_presencelike.py
+++ b/tests/handlers/test_presencelike.py
@@ -65,6 +65,8 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
                     "is_presence_visible",
 
                     "set_profile_displayname",
+
+                    "get_rooms_for_user_where_membership_is",
                 ]),
                 handlers=None,
                 resource_for_federation=Mock(),
@@ -132,6 +134,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
         # Remote user
         self.u_potato = hs.parse_userid("@potato:remote")
 
+        self.mock_get_joined = (
+            self.datastore.get_rooms_for_user_where_membership_is
+        )
+
     @defer.inlineCallbacks
     def test_set_my_state(self):
         self.presence_list = [
@@ -152,6 +158,11 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
 
     @defer.inlineCallbacks
     def test_push_local(self):
+        def get_joined(*args):
+            return defer.succeed([])
+
+        self.mock_get_joined.side_effect = get_joined
+
         self.presence_list = [
             {"observed_user_id": "@banana:test"},
             {"observed_user_id": "@clementine:test"},
diff --git a/tests/handlers/test_profile.py b/tests/handlers/test_profile.py
index f95ddd7018..5dc9b456e1 100644
--- a/tests/handlers/test_profile.py
+++ b/tests/handlers/test_profile.py
@@ -22,6 +22,7 @@ from mock import Mock
 from synapse.api.errors import AuthError
 from synapse.server import HomeServer
 from synapse.handlers.profile import ProfileHandler
+from synapse.api.constants import Membership
 
 from tests.utils import SQLiteMemoryDbPool
 
diff --git a/webclient/app.css b/webclient/app.css
index 704cd83947..736aea660c 100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -603,6 +603,10 @@ a:active  { color: #000; }
     width: auto;
 }
 
+.recentsPublicRoom {
+    font-weight: bold;
+}
+
 .recentsRoomSummaryUsersCount, .recentsRoomSummaryTS {
     color: #888;
     font-size: 12px;
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 258de9a31e..ad69d297fa 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -200,11 +200,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                     if (member) {
                         displayname = member.displayname;
                     }
+
+                    var message = event.content.body;
+                    if (event.content.msgtype === "m.emote") {
+                        message = "* " + displayname + " " + message;
+                    }
+
                     var notification = new window.Notification(
                         (displayname || event.user_id) +
                         " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
                     {
-                        "body": event.content.body,
+                        "body": message,
                         "icon": member ? member.avatar_url : undefined
                     });
                     $timeout(function() {
@@ -237,8 +243,9 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
         // add membership changes as if they were a room message if something interesting changed
         // 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) {
+        // with other other room messages XXX This is no longer true, you only get a single event, not a room message event.
+        // FIXME: This possibly reintroduces multiple join messages.
+        if (event.content.prev !== event.content.membership) { // && !isStateEvent
             if (isLiveEvent) {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
             }
@@ -369,6 +376,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                         handleMessage(event, isLiveEvent);
                         break;
                     case "m.room.member":
+                        isStateEvent = true;
                         handleRoomMember(event, isLiveEvent, isStateEvent);
                         break;
                     case "m.presence":
@@ -398,6 +406,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
         // isLiveEvents determines whether notifications should be shown, whether
         // messages get appended to the start/end of lists, etc.
         handleEvents: function(events, isLiveEvents, isStateEvents) {
+            // XXX FIXME TODO: isStateEvents is being left as undefined sometimes. It makes no sense
+            // to have isStateEvents as an arg, since things like m.room.member are ALWAYS state events.
             for (var i=0; i<events.length; i++) {
                 this.handleEvent(events[i], isLiveEvents, isStateEvents);
             }
@@ -413,6 +423,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
             if (dir && 'b' === dir) {
                 // paginateBackMessages requests messages to be in reverse chronological order
                 for (var i=0; i<events.length; i++) {
+                    // FIXME: Being live != being state
                     this.handleEvent(events[i], isLiveEvents, isLiveEvents);
                 }
                 
@@ -422,6 +433,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
             else {
                 // InitialSync returns messages in chronological order
                 for (var i=events.length - 1; i>=0; i--) {
+                    // FIXME: Being live != being state
                     this.handleEvent(events[i], isLiveEvents, isLiveEvents);
                 }
                 // Store where to start pagination
@@ -505,6 +517,18 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                 member = room.members[user_id];
             }
             return member;
+        },
+        
+        setRoomVisibility: function(room_id, visible) {
+            if (!visible) {
+                return;
+            }
+            initRoom(room_id);
+            
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                room.visibility = visible;
+            }
         }
     };
 }]);
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index 6f92332246..5af1ab2911 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -120,6 +120,8 @@ angular.module('eventStreamService', [])
                     if ("state" in room) {
                         eventHandlerService.handleEvents(room.state, false, true);
                     }
+                    
+                    eventHandlerService.setRoomVisibility(room.room_id, room.visibility);
                 }
 
                 var presence = response.data.presence;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index bf1e61ad7e..2ecb8b05ff 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -59,7 +59,7 @@ angular.module('MatrixCall', [])
         var stunServer = 'stun:stun.l.google.com:19302';
         var pc;
         if (window.mozRTCPeerConnection) {
-            pc = window.mozRTCPeerConnection({'url': stunServer});
+            pc = new window.mozRTCPeerConnection({'url': stunServer});
         } else {
             pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
         }
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 015a88bcad..8b168cdedb 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -26,72 +26,74 @@ angular.module('matrixFilter', [])
         // If there is an alias, use it
         // TODO: only one alias is managed for now
         var alias = matrixService.getRoomIdToAliasMapping(room_id);
-        if (alias) {
-            roomName = alias;
-        }
-
-        if (undefined === roomName) {
 
-            var room = $rootScope.events.rooms[room_id];
-            if (room) {
-                // Get name from room state date
-                var room_name_event = room["m.room.name"];
-                if (room_name_event) {
-                    roomName = room_name_event.content.name;
-                }
-                else if (room.members) {
-                    // Else, build the name from its users
-                    // FIXME: Is it still required?
-                    // Limit the room renaming to 1:1 room
-                    if (2 === Object.keys(room.members).length) {
-                        for (var i in room.members) {
-                            var member = room.members[i];
-                            if (member.state_key !== matrixService.config().user_id) {
+        var room = $rootScope.events.rooms[room_id];
+        if (room) {
+            // Get name from room state date
+            var room_name_event = room["m.room.name"];
+            if (room_name_event) {
+                roomName = room_name_event.content.name;
+            }
+            else if (alias) {
+                roomName = alias;
+            }
+            else if (room.members) {
+                // Else, build the name from its users
+                // FIXME: Is it still required?
+                // Limit the room renaming to 1:1 room
+                if (2 === Object.keys(room.members).length) {
+                    for (var i in room.members) {
+                        var member = room.members[i];
+                        if (member.state_key !== matrixService.config().user_id) {
 
-                                if (member.state_key in $rootScope.presence) {
-                                    // If the user is available in presence, use the displayname there
-                                    // as it is the most uptodate
-                                    roomName = $rootScope.presence[member.state_key].content.displayname;
-                                }
-                                else if (member.content.displayname) {
-                                    roomName = member.content.displayname;
-                                }
-                                else {
-                                    roomName = member.state_key;
-                                }
+                            if (member.state_key in $rootScope.presence) {
+                                // If the user is available in presence, use the displayname there
+                                // as it is the most uptodate
+                                roomName = $rootScope.presence[member.state_key].content.displayname;
                             }
-                        }
-                    }
-                    else if (1 === Object.keys(room.members).length) {
-                        // The other member may be in the invite list, get all invited users
-                        var invitedUserIDs = [];
-                        for (var i in room.messages) {
-                            var message = room.messages[i];
-                            if ("m.room.member" === message.type && "invite" === message.membership) {
-                                // Make sure there is no duplicate user
-                                if (-1 === invitedUserIDs.indexOf(message.state_key)) {
-                                    invitedUserIDs.push(message.state_key);
-                                }
-                            } 
-                        }
-                        
-                        // For now, only 1:1 room needs to be renamed. It means only 1 invited user
-                        if (1 === invitedUserIDs.length) {
-                            var userID = invitedUserIDs[0];
-
-                            // Try to resolve his displayname in presence global data
-                            if (userID in $rootScope.presence) {
-                                roomName = $rootScope.presence[userID].content.displayname;
+                            else if (member.content.displayname) {
+                                roomName = member.content.displayname;
                             }
                             else {
-                                roomName = userID;
+                                roomName = member.state_key;
                             }
                         }
                     }
                 }
+                else if (1 === Object.keys(room.members).length) {
+                    // The other member may be in the invite list, get all invited users
+                    var invitedUserIDs = [];
+                    for (var i in room.messages) {
+                        var message = room.messages[i];
+                        if ("m.room.member" === message.type && "invite" === message.membership) {
+                            // Make sure there is no duplicate user
+                            if (-1 === invitedUserIDs.indexOf(message.state_key)) {
+                                invitedUserIDs.push(message.state_key);
+                            }
+                        } 
+                    }
+
+                    // For now, only 1:1 room needs to be renamed. It means only 1 invited user
+                    if (1 === invitedUserIDs.length) {
+                        var userID = invitedUserIDs[0];
+
+                        // Try to resolve his displayname in presence global data
+                        if (userID in $rootScope.presence) {
+                            roomName = $rootScope.presence[userID].content.displayname;
+                        }
+                        else {
+                            roomName = userID;
+                        }
+                    }
+                }
             }
         }
 
+        // Always show the alias in the room displayed name
+        if (roomName && alias && alias !== roomName) {
+            roomName += " (" + alias + ")";
+        }
+
         if (undefined === roomName) {
             // By default, use the room ID
             roomName = room_id;
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index 8ba817ca68..e35219bebb 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -53,6 +53,8 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
 
                     // Add room_alias & room_display_name members
                     angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
+                    
+                    eventHandlerService.setRoomVisibility(room.room_id, "public");
                 }
             }
         );
diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js
index 2fd4dbe98b..d948205e19 100644
--- a/webclient/recents/recents-filter.js
+++ b/webclient/recents/recents-filter.js
@@ -35,9 +35,8 @@ angular.module('RecentsController')
                 // Count users here
                 // TODO: Compute it directly in eventHandlerService
                 room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
-
-                filtered.push(room);
             }
+            filtered.push(room);
         });
 
         // And time sort them
@@ -61,4 +60,4 @@ angular.module('RecentsController')
         });
         return filtered;
     };
-}]);
\ No newline at end of file
+}]);
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index e783d3a6b4..edfc1677eb 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -5,7 +5,7 @@
                class ="recentsRoom" 
                ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">                                           
             <tr>
-                <td class="recentsRoomName">
+                <td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
                     {{ room.room_id | mRoomName }}
                 </td>
                 <td class="recentsRoomSummaryUsersCount">
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 6e1d83a23d..de50058743 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -32,7 +32,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         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
+        waiting_for_joined_event: false,  // true when the join request is pending. Back to false once the corresponding m.room.member event is received
+        messages_visibility: "hidden" // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
     };
     $scope.members = {};
     $scope.autoCompleting = false;
@@ -53,8 +54,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 return;
             };
 
-            // Use the filter applied in html to set the input value
-            $scope.name.newNameText = $filter('mRoomName')($scope.room_id);
+            var nameEvent = $rootScope.events.rooms[$scope.room_id]['m.room.name'];
+            if (nameEvent) {
+                $scope.name.newNameText = nameEvent.content.name;
+            }
+            else {
+                $scope.name.newNameText = "";
+            }
 
             // Force focus to the input
             $timeout(function() {
@@ -131,6 +137,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             
             $timeout(function() {
                 objDiv.scrollTop = objDiv.scrollHeight;
+
+                // Show the message table once the first scrolldown is done 
+                if ("visible" !== $scope.state.messages_visibility) {
+                    $timeout(function() {
+                        $scope.state.messages_visibility = "visible";
+                    }, 0);
+                }
             }, 0);
         }
     };
@@ -404,12 +417,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     };
 
     $scope.send = function() {
-        if ($scope.textInput === "") {
+        if (undefined === $scope.textInput || $scope.textInput === "") {
             return;
         }
         
         scrollToBottom(true);
-        
+
+        // Store the command in the history
+        history.push($scope.textInput);
+
         var promise;
         var cmd;
         var args;
@@ -735,7 +751,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         // Make recents highlight the current room
         $scope.recentsSelectedRoomID = $scope.room_id;
 
-		// Get the up-to-date the current member list
+        // Init the history for this room
+        history.init();
+
+        // Get the up-to-date the current member list
         matrixService.getMemberList($scope.room_id).then(
             function(response) {
                 for (var i = 0; i < response.data.chunk.length; i++) {
@@ -847,4 +866,82 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         $rootScope.currentCall = call;
     };
 
+    // Manage history of typed messages
+    // History is saved in sessionStoratge so that it survives when the user
+    // navigates through the rooms and when it refreshes the page
+    var history = {
+        // The list of typed messages. Index 0 is the more recents
+        data: [],
+
+        // The position in the history currently displayed
+        position: -1,
+
+        // The message the user has started to type before going into the history
+        typingMessage: undefined,
+
+        // Init/load data for the current room
+        init: function() {
+            var data = sessionStorage.getItem("history_" + $scope.room_id);
+            if (data) {
+                this.data = JSON.parse(data);
+            }
+        },
+
+        // Store a message in the history
+        push: function(message) {
+            this.data.unshift(message);
+
+            // Update the session storage
+            sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data));
+
+            // Reset history position
+            this.position = -1;
+            this.typingMessage = undefined;
+        },
+
+        // Move in the history
+        go: function(offset) {
+
+            if (-1 === this.position) {
+                // User starts to go to into the history, save the current line
+                this.typingMessage = $scope.textInput;
+            }
+            else {
+                // If the user modified this line in history, keep the change
+                this.data[this.position] = $scope.textInput;
+            }
+
+            // Bounds the new position to valid data
+            var newPosition = this.position + offset;
+            newPosition = Math.max(-1, newPosition);
+            newPosition = Math.min(newPosition, this.data.length - 1);
+            this.position = newPosition;
+
+            if (-1 !== this.position) {
+                // Show the message from the history
+                $scope.textInput = this.data[this.position];
+            }
+            else if (undefined !== this.typingMessage) {
+                // Go back to the message the user started to type
+                $scope.textInput = this.typingMessage;
+            }
+        }
+    };
+
+    // Make history singleton methods available from HTML
+    $scope.history = {
+        goUp: function($event) {
+            if ($scope.room_id) {
+                history.go(1);
+            }
+            $event.preventDefault();
+        },
+        goDown: function($event) {
+            if ($scope.room_id) {
+                history.go(-1);
+            }
+            $event.preventDefault();
+        }
+    };
+
 }]);
diff --git a/webclient/room/room.html b/webclient/room/room.html
index 886c2afe64..44a0e34d9f 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -9,7 +9,7 @@
                     {{ room_id | mRoomName }}
                 </div>
                 <form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm">
-                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" />
+                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/>
                 </form>
             </div>
 
@@ -23,7 +23,7 @@
                         {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}}
                     </div>
                     <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
-                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" />
+                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput"  placeholder="Topic"/>
                     </form>
                 </div>
             </div>
@@ -56,7 +56,10 @@
         </table>
     </div>
     
-    <div id="messageTableWrapper" ng-hide="state.permission_denied" keep-scroll>
+    <div id="messageTableWrapper" 
+         ng-hide="state.permission_denied" 
+         ng-style="{ 'visibility': state.messages_visibility }"
+         keep-scroll>
         <!-- FIXME: need to have better timestamp semantics than the (msg.content.hsob_ts || msg.ts) hack below -->
         <table id="messageTable" infinite-scroll="paginateMore()">
             <tr ng-repeat="msg in events.rooms[room_id].messages"
@@ -156,7 +159,8 @@
                     <td width="*">
                         <textarea id="mainInput" rows="1" ng-model="textInput" ng-enter="send()"
                                   ng-disabled="state.permission_denied"
-                                  ng-focus="true"  autocomplete="off" tab-complete/>
+                                  ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
+                                  ng-focus="true" autocomplete="off" tab-complete/>
                     </td>
                     <td id="buttonsCell">
                         <button ng-click="send()" ng-disabled="state.permission_denied">Send</button>