From 774c3dbd3851b4b2d8ea9acc35db000c624f01d6 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Thu, 28 Sep 2017 09:42:33 -0600
Subject: [PATCH 01/15] Pin/unpin message option in a message's context menu

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/context_menus/MessageContextMenu.js | 30 +++++++++++++++++++
 src/i18n/strings/en_EN.json                   |  2 ++
 src/i18n/strings/en_US.json                   |  2 ++
 3 files changed, 34 insertions(+)

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
index d6d9302f..09503f99 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -65,6 +65,13 @@ module.exports = React.createClass({
         this.setState({canRedact});
     },
 
+    _isPinned: function() {
+        const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
+        const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
+        if (!pinnedEvent) return false;
+        return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
+    },
+
     onResendClick: function() {
         Resend.resend(this.props.mxEvent);
         this.closeMenu();
@@ -122,6 +129,22 @@ module.exports = React.createClass({
         this.closeMenu();
     },
 
+    onPinClick: function() {
+        MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '').then(event => {
+            const eventIds = (event ? event.pinned : []) || [];
+            if (!eventIds.includes(this.props.mxEvent.getId())) {
+                // Not pinned - add
+                eventIds.push(this.props.mxEvent.getId());
+            } else {
+                // Pinned - remove
+                eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
+            }
+
+            MatrixClientPeg.get().sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
+        });
+        this.closeMenu();
+    },
+
     closeMenu: function() {
         if (this.props.onFinished) this.props.onFinished();
     },
@@ -147,6 +170,7 @@ module.exports = React.createClass({
         let redactButton;
         let cancelButton;
         let forwardButton;
+        let pinButton;
         let viewSourceButton;
         let viewClearSourceButton;
         let unhidePreviewButton;
@@ -186,6 +210,11 @@ module.exports = React.createClass({
                         { _t('Forward Message') }
                     </div>
                 );
+                pinButton = (
+                    <div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
+                        { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
+                    </div>
+                );
             }
         }
 
@@ -246,6 +275,7 @@ module.exports = React.createClass({
                 {redactButton}
                 {cancelButton}
                 {forwardButton}
+                {pinButton}
                 {viewSourceButton}
                 {viewClearSourceButton}
                 {unhidePreviewButton}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 34337746..9b1780f7 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -213,6 +213,8 @@
   "You have successfully set a password!": "You have successfully set a password!",
   "You can now return to your account after signing out, and sign in on other devices.": "You can now return to your account after signing out, and sign in on other devices.",
   "Continue": "Continue",
+  "Pin Message": "Pin Message",
+  "Unpin Message": "Unpin Message",
   "Please set a password!": "Please set a password!",
   "This will allow you to return to your account after signing out, and sign in on other devices.": "This will allow you to return to your account after signing out, and sign in on other devices.",
   "You have successfully set a password and an email address!": "You have successfully set a password and an email address!",
diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json
index 4eded616..1d2fc1c9 100644
--- a/src/i18n/strings/en_US.json
+++ b/src/i18n/strings/en_US.json
@@ -199,6 +199,8 @@
     "You have successfully set a password and an email address!": "You have successfully set a password and an email address!",
     "Remember, you can always set an email address in user settings if you change your mind.": "Remember, you can always set an email address in user settings if you change your mind.",
     "Warning": "Warning",
+    "Pin Message": "Pin Message",
+    "Unpin Message": "Unpin Message",
     "Checking for an update...": "Checking for an update...",
     "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).",
     "No update available.": "No update available.",

From 5daa16ab53c685666b2ce2ac0ef3a55571406e32 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Thu, 28 Sep 2017 15:48:32 -0600
Subject: [PATCH 02/15] Add panel for pinned messages.

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/rooms/PinnedEventsPanel.js          | 106 ++++++++++++++++++
 src/skins/vector/css/_components.scss         |   1 +
 .../views/rooms/_PinnedEventsPanel.scss       |  67 +++++++++++
 src/skins/vector/img/icons-pin.svg            |   7 ++
 4 files changed, 181 insertions(+)
 create mode 100644 src/components/views/rooms/PinnedEventsPanel.js
 create mode 100644 src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
 create mode 100644 src/skins/vector/img/icons-pin.svg

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
new file mode 100644
index 00000000..4e5efdd3
--- /dev/null
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -0,0 +1,106 @@
+/*
+Copyright 2017 Travis Ralston
+
+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';
+
+var React = require('react');
+var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
+var sdk = require('matrix-react-sdk');
+var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+import { _t } from "matrix-react-sdk/lib/languageHandler";
+import { EventTimeline } from "matrix-js-sdk";
+
+module.exports = React.createClass({
+    displayName: 'PinnedEventsPanel',
+    propTypes: {
+        // The Room from the js-sdk we're going to show pinned events for
+        room: React.PropTypes.object.isRequired,
+
+        onCancelClick: React.PropTypes.func,
+    },
+
+    getInitialState: function() {
+        return {
+            loading: true,
+        };
+    },
+
+    componentDidMount: function() {
+        const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
+        if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
+            this.setState({ loading: false, pinned: [] });
+        } else {
+            const promises = [];
+            const cli = MatrixClientPeg.get();
+
+            pinnedEvents.getContent().pinned.map(eventId => {
+                promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(timeline => {
+                    return {eventId, timeline};
+                }));
+            });
+
+            Promise.all(promises).then(contexts => {
+                this.setState({ loading: false, pinned: contexts });
+            });
+        }
+    },
+
+    _getPinnedTiles: function() {
+        const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
+        const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
+
+        if (this.state.pinned.length == 0) {
+            return <div>No pinned messages.</div>;
+        }
+
+        return this.state.pinned.map(pinnedEvent => {
+            const event = pinnedEvent.timeline.getEvents().find(e => e.getId() === pinnedEvent.eventId);
+            const sender = this.props.room.getMember(event.getSender());
+            const avatarSize = 40;
+
+            // Don't show non-messages. Technically users can pin state/custom events, but we won't
+            // support those events.
+            if (event.getType() !== "m.room.message") return '';
+
+            return (
+                <div key={"pinnedEvent_" + pinnedEvent.eventId} className="mx_PinnedEventsPanel_pinnedEvent">
+                    <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
+                    <span className="mx_PinnedEventsPanel_sender">
+                        {sender.name}
+                    </span>
+                    <MessageEvent mxEvent={event} className="mx_PinnedEventsPanel_body" />
+                </div>
+            );
+        });
+    },
+
+    render: function() {
+        let tiles = <div>Loading...</div>;
+        if (this.state && !this.state.loading) {
+            tiles = this._getPinnedTiles();
+        }
+
+        return (
+            <div className="mx_PinnedEventsPanel">
+                <div className="mx_PinnedEventsPanel_body">
+                    <AccessibleButton className="mx_PinnedEventsPanel_cancel" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
+                    <h3 className="mx_PinnedEventsPanel_header">{_t("Pinned Messages")}</h3>
+                    { tiles }
+                </div>
+            </div>
+        );
+    }
+});
diff --git a/src/skins/vector/css/_components.scss b/src/skins/vector/css/_components.scss
index 61bfa104..1d351c82 100644
--- a/src/skins/vector/css/_components.scss
+++ b/src/skins/vector/css/_components.scss
@@ -88,4 +88,5 @@
 @import "./vector-web/views/rooms/_RoomDropTarget.scss";
 @import "./vector-web/views/rooms/_RoomTooltip.scss";
 @import "./vector-web/views/rooms/_SearchBar.scss";
+@import "./vector-web/views/rooms/_PinnedEventsPanel.scss";
 @import "./vector-web/views/settings/_Notifications.scss";
diff --git a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
new file mode 100644
index 00000000..883eaa26
--- /dev/null
+++ b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
@@ -0,0 +1,67 @@
+/*
+Copyright 2017 Travis Ralston
+
+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.
+*/
+
+.mx_PinnedEventsPanel {
+    border-top: 1px solid $primary-hairline-color;
+}
+
+.mx_PinnedEventsPanel_body {
+    max-height: 300px;
+    overflow-y: scroll;
+}
+
+.mx_PinnedEventsPanel_header {
+    margin: 0;
+    padding-top: 8px;
+    padding-bottom: 15px;
+}
+
+.mx_PinnedEventsPanel_pinnedEvent {
+    min-height: 40px;
+    margin-bottom: 5px;
+    cursor: pointer;
+    width: 100%;
+    border-radius: 5px; // for the hover
+}
+
+.mx_PinnedEventsPanel_pinnedEvent:hover {
+    background-color: $event-selected-color;
+}
+
+.mx_PinnedEventsPanel_pinnedEvent .mx_PinnedEventsPanel_sender {
+    color: #868686;
+    font-size: 0.8em;
+    vertical-align: top;
+    display: block;
+}
+
+.mx_PinnedEventsPanel_pinnedEvent .mx_EventTile_content {
+    margin-left: 50px;
+    position: relative;
+    top: 0;
+    left: 0;
+}
+
+.mx_PinnedEventsPanel_pinnedEvent .mx_BaseAvatar {
+    float: left;
+    margin-right: 10px;
+}
+
+.mx_PinnedEventsPanel_cancel {
+    margin: 12px;
+    float: right;
+    display: inline-block;
+}
diff --git a/src/skins/vector/img/icons-pin.svg b/src/skins/vector/img/icons-pin.svg
new file mode 100644
index 00000000..a6fbf13b
--- /dev/null
+++ b/src/skins/vector/img/icons-pin.svg
@@ -0,0 +1,7 @@
+<svg width="16px" height="16px" viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <g transform="scale(0.03125)">
+  <path id="svg_2" fill="none" stroke="#76cfa6" stroke-width="40" stroke-linecap="round" stroke-linejoin="round" d="m315.802,402.338c12.73,-33.537 13.503,-69.629 3.623,-102.697l93.245,-103.107l7.831,7.831c10.411,10.409 27.283,10.409 37.691,0c10.41,-10.408 10.41,-27.281 0.001,-37.69l-112.869,-112.867c-10.407,-10.409 -27.279,-10.41 -37.689,-0.001c-10.408,10.41 -10.409,27.283 0.001,37.693l7.833,7.833l-103.107,93.243c-33.069,-9.878 -69.163,-9.107 -102.697,3.626c-4.7,1.785 -8.001,5.646 -9.059,10.604c-1.175,5.473 0.627,11.402 4.697,15.472l184.42,184.421c4.069,4.07 10,5.871 15.472,4.695c4.959,-1.055 8.82,-4.357 10.607,-9.056z"/>
+  <polyline id="svg_3" fill="none" stroke="#76cfa6" stroke-width="40" stroke-linecap="round" stroke-linejoin="round" points="    180.951,297.927 46,466 215.319,332.295   "/>
+  <!--<line id="svg_4" fill="none" stroke="#76cfa6" stroke-width="40" stroke-linecap="round" stroke-linejoin="round" y2="219.549" y1="138.166" x2="255.531" x1="336.915"/>-->
+ </g>
+</svg>
\ No newline at end of file

From fa5a23e0df3724ce0a3d80772503d0105b6c0af8 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Thu, 28 Sep 2017 16:59:22 -0600
Subject: [PATCH 03/15] Permalink pins to their original events

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/rooms/PinnedEventsPanel.js          | 50 +++++++++++++------
 .../views/rooms/_PinnedEventsPanel.scss       | 10 ++--
 2 files changed, 40 insertions(+), 20 deletions(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index 4e5efdd3..a5d5d2ed 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -20,9 +20,43 @@ var React = require('react');
 var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
 var sdk = require('matrix-react-sdk');
 var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
+var dis = require('matrix-react-sdk/lib/dispatcher');
 import { _t } from "matrix-react-sdk/lib/languageHandler";
 import { EventTimeline } from "matrix-js-sdk";
 
+const PinnedEventTile = React.createClass({
+    displayName: 'PinnedEventTile',
+    propTypes: {
+        mxRoom: React.PropTypes.object.isRequired,
+        mxEvent: React.PropTypes.object.isRequired,
+    },
+    onTileClicked: function() {
+        dis.dispatch({
+            action: 'view_room',
+            event_id: this.props.mxEvent.getId(),
+            highlighted: true,
+            room_id: this.props.mxEvent.getRoomId(),
+        });
+    },
+    render: function() {
+        const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
+        const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
+
+        const sender = this.props.mxRoom.getMember(this.props.mxEvent.getSender());
+        const avatarSize = 40;
+
+        return (
+            <div className="mx_PinnedEventTile" onClick={this.onTileClicked}>
+                <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
+                <span className="mx_PinnedEventTile_sender">
+                    {sender.name}
+                </span>
+                <MessageEvent mxEvent={this.props.mxEvent} className="mx_PinnedEventTile_body" />
+            </div>
+        );
+    }
+});
+
 module.exports = React.createClass({
     displayName: 'PinnedEventsPanel',
     propTypes: {
@@ -59,31 +93,17 @@ module.exports = React.createClass({
     },
 
     _getPinnedTiles: function() {
-        const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
-        const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
-
         if (this.state.pinned.length == 0) {
             return <div>No pinned messages.</div>;
         }
 
         return this.state.pinned.map(pinnedEvent => {
             const event = pinnedEvent.timeline.getEvents().find(e => e.getId() === pinnedEvent.eventId);
-            const sender = this.props.room.getMember(event.getSender());
-            const avatarSize = 40;
 
             // Don't show non-messages. Technically users can pin state/custom events, but we won't
             // support those events.
             if (event.getType() !== "m.room.message") return '';
-
-            return (
-                <div key={"pinnedEvent_" + pinnedEvent.eventId} className="mx_PinnedEventsPanel_pinnedEvent">
-                    <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
-                    <span className="mx_PinnedEventsPanel_sender">
-                        {sender.name}
-                    </span>
-                    <MessageEvent mxEvent={event} className="mx_PinnedEventsPanel_body" />
-                </div>
-            );
+            return (<PinnedEventTile key={event.getId()} mxRoom={this.props.room} mxEvent={event} />);
         });
     },
 
diff --git a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
index 883eaa26..133fa703 100644
--- a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
+++ b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
@@ -29,7 +29,7 @@ limitations under the License.
     padding-bottom: 15px;
 }
 
-.mx_PinnedEventsPanel_pinnedEvent {
+.mx_PinnedEventTile {
     min-height: 40px;
     margin-bottom: 5px;
     cursor: pointer;
@@ -37,25 +37,25 @@ limitations under the License.
     border-radius: 5px; // for the hover
 }
 
-.mx_PinnedEventsPanel_pinnedEvent:hover {
+.mx_PinnedEventTile:hover {
     background-color: $event-selected-color;
 }
 
-.mx_PinnedEventsPanel_pinnedEvent .mx_PinnedEventsPanel_sender {
+.mx_PinnedEventTile .mx_PinnedEventTile_sender {
     color: #868686;
     font-size: 0.8em;
     vertical-align: top;
     display: block;
 }
 
-.mx_PinnedEventsPanel_pinnedEvent .mx_EventTile_content {
+.mx_PinnedEventTile .mx_EventTile_content {
     margin-left: 50px;
     position: relative;
     top: 0;
     left: 0;
 }
 
-.mx_PinnedEventsPanel_pinnedEvent .mx_BaseAvatar {
+.mx_PinnedEventTile .mx_BaseAvatar {
     float: left;
     margin-right: 10px;
 }

From 9b11f576fecc1721f77cdf19ce594e13eed36c59 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 10:41:23 -0600
Subject: [PATCH 04/15] Add action bar to pinned event tiles; support unpinning
 from the panel

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/rooms/PinnedEventsPanel.js          | 33 +++++++++++++++++--
 .../views/rooms/_PinnedEventsPanel.scss       | 22 ++++++++++++-
 2 files changed, 52 insertions(+), 3 deletions(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index a5d5d2ed..53bf3db6 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -29,6 +29,7 @@ const PinnedEventTile = React.createClass({
     propTypes: {
         mxRoom: React.PropTypes.object.isRequired,
         mxEvent: React.PropTypes.object.isRequired,
+        onUnpinned: React.PropTypes.func,
     },
     onTileClicked: function() {
         dis.dispatch({
@@ -38,6 +39,22 @@ const PinnedEventTile = React.createClass({
             room_id: this.props.mxEvent.getRoomId(),
         });
     },
+    onUnpinClicked: function() {
+        const pinnedEvents = this.props.mxRoom.currentState.getStateEvents("m.room.pinned_events", "");
+        if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
+            // Nothing to do: already unpinned
+            if (this.props.onUnpinned) this.props.onUnpinned();
+        } else {
+            const pinned = pinnedEvents.getContent().pinned;
+            const index = pinned.indexOf(this.props.mxEvent.getId());
+            if (index !== -1) {
+                pinned.splice(index, 1);
+                MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '').then(() => {
+                    if (this.props.onUnpinned) this.props.onUnpinned();
+                });
+            } else if (this.props.onUnpinned) this.props.onUnpinned();
+        }
+    },
     render: function() {
         const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
         const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
@@ -46,7 +63,15 @@ const PinnedEventTile = React.createClass({
         const avatarSize = 40;
 
         return (
-            <div className="mx_PinnedEventTile" onClick={this.onTileClicked}>
+            <div className="mx_PinnedEventTile">
+                <div className="mx_PinnedEventTile_actions">
+                    <AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
+                        Jump to message
+                    </AccessibleButton>
+                    <img src="img/cancel-red.svg" className="mx_PinnedEventTile_unpinButton" width="8" height="8"
+                         onClick={this.onUnpinClicked} alt={_t('Unpin Message')} title={_t('Unpin Message')} />
+                </div>
+
                 <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
                 <span className="mx_PinnedEventTile_sender">
                     {sender.name}
@@ -73,6 +98,10 @@ module.exports = React.createClass({
     },
 
     componentDidMount: function() {
+        this._updatePinnedMessages();
+    },
+
+    _updatePinnedMessages: function() {
         const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
         if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
             this.setState({ loading: false, pinned: [] });
@@ -103,7 +132,7 @@ module.exports = React.createClass({
             // Don't show non-messages. Technically users can pin state/custom events, but we won't
             // support those events.
             if (event.getType() !== "m.room.message") return '';
-            return (<PinnedEventTile key={event.getId()} mxRoom={this.props.room} mxEvent={event} />);
+            return (<PinnedEventTile key={event.getId()} mxRoom={this.props.room} mxEvent={event} onUnpinned={this._updatePinnedMessages} />);
         });
     },
 
diff --git a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
index 133fa703..a2041cfe 100644
--- a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
+++ b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
@@ -32,7 +32,6 @@ limitations under the License.
 .mx_PinnedEventTile {
     min-height: 40px;
     margin-bottom: 5px;
-    cursor: pointer;
     width: 100%;
     border-radius: 5px; // for the hover
 }
@@ -46,6 +45,7 @@ limitations under the License.
     font-size: 0.8em;
     vertical-align: top;
     display: block;
+    padding-bottom: 3px;
 }
 
 .mx_PinnedEventTile .mx_EventTile_content {
@@ -65,3 +65,23 @@ limitations under the License.
     float: right;
     display: inline-block;
 }
+
+.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions {
+    display: block;
+}
+
+.mx_PinnedEventTile_actions {
+    float: right;
+    margin-right: 10px;
+    display: none;
+}
+
+.mx_PinnedEventTile_unpinButton {
+    cursor: pointer;
+    margin-left: 10px;
+}
+
+.mx_PinnedEventTile_gotoButton {
+    display: inline-block;
+    font-size: 0.8em;
+}

From 59a253af37dcfec7284d963f8122ccc2bd25191c Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 10:53:44 -0600
Subject: [PATCH 05/15] Don't show the unpin button if the user can't unpin the
 message.

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/rooms/PinnedEventsPanel.js      | 12 ++++++++++--
 .../vector-web/views/rooms/_PinnedEventsPanel.scss   |  3 ++-
 2 files changed, 12 insertions(+), 3 deletions(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index 53bf3db6..8f898b11 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -55,6 +55,9 @@ const PinnedEventTile = React.createClass({
             } else if (this.props.onUnpinned) this.props.onUnpinned();
         }
     },
+    _canUnpin: function() {
+        return this.props.mxRoom.currentState.mayClientSendStateEvent('m.room.pinned_events', MatrixClientPeg.get());
+    },
     render: function() {
         const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
         const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
@@ -62,14 +65,19 @@ const PinnedEventTile = React.createClass({
         const sender = this.props.mxRoom.getMember(this.props.mxEvent.getSender());
         const avatarSize = 40;
 
+        let unpinButton = null;
+        if (this._canUnpin()) {
+            unpinButton = <img src="img/cancel-red.svg" className="mx_PinnedEventTile_unpinButton" width="8" height="8"
+                               onClick={this.onUnpinClicked} alt={_t('Unpin Message')} title={_t('Unpin Message')} />;
+        }
+
         return (
             <div className="mx_PinnedEventTile">
                 <div className="mx_PinnedEventTile_actions">
                     <AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
                         Jump to message
                     </AccessibleButton>
-                    <img src="img/cancel-red.svg" className="mx_PinnedEventTile_unpinButton" width="8" height="8"
-                         onClick={this.onUnpinClicked} alt={_t('Unpin Message')} title={_t('Unpin Message')} />
+                    { unpinButton }
                 </div>
 
                 <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
diff --git a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
index a2041cfe..44297cfa 100644
--- a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
+++ b/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
@@ -20,7 +20,8 @@ limitations under the License.
 
 .mx_PinnedEventsPanel_body {
     max-height: 300px;
-    overflow-y: scroll;
+    overflow-y: auto;
+    padding-bottom: 15px;
 }
 
 .mx_PinnedEventsPanel_header {

From efdb4b02c6dfaafc315ce3d6dd8f7435034f3c07 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 10:55:52 -0600
Subject: [PATCH 06/15] i18n for 'Jump to message' button

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/rooms/PinnedEventsPanel.js | 2 +-
 src/i18n/strings/en_EN.json                     | 1 +
 src/i18n/strings/en_US.json                     | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index 8f898b11..fba0d828 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -75,7 +75,7 @@ const PinnedEventTile = React.createClass({
             <div className="mx_PinnedEventTile">
                 <div className="mx_PinnedEventTile_actions">
                     <AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
-                        Jump to message
+                        { _t("Jump to message") }
                     </AccessibleButton>
                     { unpinButton }
                 </div>
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 9b1780f7..d403c7c1 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -215,6 +215,7 @@
   "Continue": "Continue",
   "Pin Message": "Pin Message",
   "Unpin Message": "Unpin Message",
+  "Jump to message": "Jump to message",
   "Please set a password!": "Please set a password!",
   "This will allow you to return to your account after signing out, and sign in on other devices.": "This will allow you to return to your account after signing out, and sign in on other devices.",
   "You have successfully set a password and an email address!": "You have successfully set a password and an email address!",
diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json
index 1d2fc1c9..cc42d785 100644
--- a/src/i18n/strings/en_US.json
+++ b/src/i18n/strings/en_US.json
@@ -201,6 +201,7 @@
     "Warning": "Warning",
     "Pin Message": "Pin Message",
     "Unpin Message": "Unpin Message",
+    "Jump to message": "Jump to message",
     "Checking for an update...": "Checking for an update...",
     "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).",
     "No update available.": "No update available.",

From f7389b70aac8c2d933c24b3cff7ad3324ff9db91 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:01:17 -0600
Subject: [PATCH 07/15] Hide the pin option in the context menu if the user
 can't pin messages

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/context_menus/MessageContextMenu.js | 27 ++++++++++++-------
 1 file changed, 17 insertions(+), 10 deletions(-)

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
index 09503f99..bc76fd25 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -43,26 +43,30 @@ module.exports = React.createClass({
     getInitialState: function() {
         return {
             canRedact: false,
+            canPin: false,
         };
     },
 
     componentWillMount: function() {
-        MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkCanRedact);
-        this._checkCanRedact();
+        MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
+        this._checkPermissions();
     },
 
     componentWillUnmount: function() {
         const cli = MatrixClientPeg.get();
         if (cli) {
-            cli.removeListener('RoomMember.powerLevel', this._checkCanRedact);
+            cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
         }
     },
 
-    _checkCanRedact: function() {
+    _checkPermissions: function() {
         const cli = MatrixClientPeg.get();
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
+
         const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
-        this.setState({canRedact});
+        const canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
+
+        this.setState({canRedact, canPin});
     },
 
     _isPinned: function() {
@@ -210,11 +214,14 @@ module.exports = React.createClass({
                         { _t('Forward Message') }
                     </div>
                 );
-                pinButton = (
-                    <div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
-                        { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
-                    </div>
-                );
+
+                if (this.state.canPin) {
+                    pinButton = (
+                        <div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
+                            {this._isPinned() ? _t('Unpin Message') : _t('Pin Message')}
+                        </div>
+                    );
+                }
             }
         }
 

From 965a25ba84aaa4aed0b98dba0cbd7e60d1463e0c Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:08:22 -0600
Subject: [PATCH 08/15] Fix bug where rooms missing m.room.pinned_events could
 not pin messages

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/context_menus/MessageContextMenu.js | 28 +++++++++++--------
 1 file changed, 17 insertions(+), 11 deletions(-)

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
index bc76fd25..a04bf274 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -134,18 +134,24 @@ module.exports = React.createClass({
     },
 
     onPinClick: function() {
-        MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '').then(event => {
-            const eventIds = (event ? event.pinned : []) || [];
-            if (!eventIds.includes(this.props.mxEvent.getId())) {
-                // Not pinned - add
-                eventIds.push(this.props.mxEvent.getId());
-            } else {
-                // Pinned - remove
-                eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
-            }
+        MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
+            .then(null, e => {
+                // Intercept the Event Not Found error and fall through the promise chain with no event.
+                if (e.errcode === "M_NOT_FOUND") return null;
+                throw e;
+            })
+            .then(event => {
+                const eventIds = (event ? event.pinned : []) || [];
+                if (!eventIds.includes(this.props.mxEvent.getId())) {
+                    // Not pinned - add
+                    eventIds.push(this.props.mxEvent.getId());
+                } else {
+                    // Pinned - remove
+                    eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
+                }
 
-            MatrixClientPeg.get().sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
-        });
+                MatrixClientPeg.get().sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
+            });
         this.closeMenu();
     },
 

From aff02885def8e0ccf8ae4e5118754dfd6de26bfd Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:21:21 -0600
Subject: [PATCH 09/15] Don't fail if an event doesn't belong to a room.

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/rooms/PinnedEventsPanel.js | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index fba0d828..7454e104 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -120,11 +120,16 @@ module.exports = React.createClass({
             pinnedEvents.getContent().pinned.map(eventId => {
                 promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(timeline => {
                     return {eventId, timeline};
+                }).catch(err => {
+                    console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
+                    console.error(err);
+                    return null; // return lack of context to avoid unhandled errors
                 }));
             });
 
             Promise.all(promises).then(contexts => {
-                this.setState({ loading: false, pinned: contexts });
+                // Filter out the contexts that may have failed early by doing a truthy test
+                this.setState({ loading: false, pinned: contexts.filter(c => c) });
             });
         }
     },

From 0a3bc1c6675e4cd9e1d9f58d6cd4516691ffc190 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:21:44 -0600
Subject: [PATCH 10/15] Don't try to show redacted messages

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/rooms/PinnedEventsPanel.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index 7454e104..d86d4b58 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -145,6 +145,7 @@ module.exports = React.createClass({
             // Don't show non-messages. Technically users can pin state/custom events, but we won't
             // support those events.
             if (event.getType() !== "m.room.message") return '';
+            if (event.isRedacted()) return ''; // don't show redacted pins
             return (<PinnedEventTile key={event.getId()} mxRoom={this.props.room} mxEvent={event} onUnpinned={this._updatePinnedMessages} />);
         });
     },

From b0190f6a65a57c6d2f73bfdbc72aa77e045a1d1d Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:22:06 -0600
Subject: [PATCH 11/15] i18n for remaining strings

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/rooms/PinnedEventsPanel.js | 4 ++--
 src/i18n/strings/en_EN.json                     | 2 ++
 src/i18n/strings/en_US.json                     | 2 ++
 3 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index d86d4b58..ec50ae07 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -136,7 +136,7 @@ module.exports = React.createClass({
 
     _getPinnedTiles: function() {
         if (this.state.pinned.length == 0) {
-            return <div>No pinned messages.</div>;
+            return <div>{ _t("No pinned messages.") }</div>;
         }
 
         return this.state.pinned.map(pinnedEvent => {
@@ -151,7 +151,7 @@ module.exports = React.createClass({
     },
 
     render: function() {
-        let tiles = <div>Loading...</div>;
+        let tiles = <div>{ _t("Loading...") }</div>;
         if (this.state && !this.state.loading) {
             tiles = this._getPinnedTiles();
         }
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index d403c7c1..7f2d1185 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -216,6 +216,8 @@
   "Pin Message": "Pin Message",
   "Unpin Message": "Unpin Message",
   "Jump to message": "Jump to message",
+  "No pinned messages.": "No pinned messages.",
+  "Loading...": "Loading...",
   "Please set a password!": "Please set a password!",
   "This will allow you to return to your account after signing out, and sign in on other devices.": "This will allow you to return to your account after signing out, and sign in on other devices.",
   "You have successfully set a password and an email address!": "You have successfully set a password and an email address!",
diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json
index cc42d785..66042713 100644
--- a/src/i18n/strings/en_US.json
+++ b/src/i18n/strings/en_US.json
@@ -202,6 +202,8 @@
     "Pin Message": "Pin Message",
     "Unpin Message": "Unpin Message",
     "Jump to message": "Jump to message",
+    "No pinned messages.": "No pinned messages.",
+    "Loading...": "Loading...",
     "Checking for an update...": "Checking for an update...",
     "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).",
     "No update available.": "No update available.",

From 2d5acfc0bc1337a0e816e37da5970e23272becfd Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 11:30:52 -0600
Subject: [PATCH 12/15] Filter pinned events before rendering

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/rooms/PinnedEventsPanel.js          | 24 ++++++++++---------
 1 file changed, 13 insertions(+), 11 deletions(-)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index ec50ae07..e7da2ff2 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -119,7 +119,8 @@ module.exports = React.createClass({
 
             pinnedEvents.getContent().pinned.map(eventId => {
                 promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(timeline => {
-                    return {eventId, timeline};
+                    const event = timeline.getEvents().find(e => e.getId() === eventId);
+                    return {eventId, timeline, event};
                 }).catch(err => {
                     console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
                     console.error(err);
@@ -128,8 +129,15 @@ module.exports = React.createClass({
             });
 
             Promise.all(promises).then(contexts => {
-                // Filter out the contexts that may have failed early by doing a truthy test
-                this.setState({ loading: false, pinned: contexts.filter(c => c) });
+                // Filter out the messages before we try to render them
+                const pinned = contexts.filter(context => {
+                    if (!context) return false; // no context == not applicable for the room
+                    if (context.event.getType() !== "m.room.message") return false;
+                    if (context.event.isRedacted()) return false;
+                    return true;
+                });
+
+                this.setState({ loading: false, pinned });
             });
         }
     },
@@ -139,14 +147,8 @@ module.exports = React.createClass({
             return <div>{ _t("No pinned messages.") }</div>;
         }
 
-        return this.state.pinned.map(pinnedEvent => {
-            const event = pinnedEvent.timeline.getEvents().find(e => e.getId() === pinnedEvent.eventId);
-
-            // Don't show non-messages. Technically users can pin state/custom events, but we won't
-            // support those events.
-            if (event.getType() !== "m.room.message") return '';
-            if (event.isRedacted()) return ''; // don't show redacted pins
-            return (<PinnedEventTile key={event.getId()} mxRoom={this.props.room} mxEvent={event} onUnpinned={this._updatePinnedMessages} />);
+        return this.state.pinned.map(context => {
+            return (<PinnedEventTile key={context.event.getId()} mxRoom={this.props.room} mxEvent={context.event} onUnpinned={this._updatePinnedMessages} />);
         });
     },
 

From 078ee54edfffa05781ce65ce74a42678d7ce3b7d Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 13:15:21 -0600
Subject: [PATCH 13/15] Move the PinnedEventsPanel to the react-sdk

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/rooms/PinnedEventsPanel.js          | 171 ------------------
 src/skins/vector/css/_components.scss         |   2 +-
 .../views/rooms/_PinnedEventsPanel.scss       |   0
 3 files changed, 1 insertion(+), 172 deletions(-)
 delete mode 100644 src/components/views/rooms/PinnedEventsPanel.js
 rename src/skins/vector/css/{vector-web => matrix-react-sdk}/views/rooms/_PinnedEventsPanel.scss (100%)

diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
deleted file mode 100644
index e7da2ff2..00000000
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
-Copyright 2017 Travis Ralston
-
-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';
-
-var React = require('react');
-var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg');
-var sdk = require('matrix-react-sdk');
-var AccessibleButton = require('matrix-react-sdk/lib/components/views/elements/AccessibleButton');
-var dis = require('matrix-react-sdk/lib/dispatcher');
-import { _t } from "matrix-react-sdk/lib/languageHandler";
-import { EventTimeline } from "matrix-js-sdk";
-
-const PinnedEventTile = React.createClass({
-    displayName: 'PinnedEventTile',
-    propTypes: {
-        mxRoom: React.PropTypes.object.isRequired,
-        mxEvent: React.PropTypes.object.isRequired,
-        onUnpinned: React.PropTypes.func,
-    },
-    onTileClicked: function() {
-        dis.dispatch({
-            action: 'view_room',
-            event_id: this.props.mxEvent.getId(),
-            highlighted: true,
-            room_id: this.props.mxEvent.getRoomId(),
-        });
-    },
-    onUnpinClicked: function() {
-        const pinnedEvents = this.props.mxRoom.currentState.getStateEvents("m.room.pinned_events", "");
-        if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
-            // Nothing to do: already unpinned
-            if (this.props.onUnpinned) this.props.onUnpinned();
-        } else {
-            const pinned = pinnedEvents.getContent().pinned;
-            const index = pinned.indexOf(this.props.mxEvent.getId());
-            if (index !== -1) {
-                pinned.splice(index, 1);
-                MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '').then(() => {
-                    if (this.props.onUnpinned) this.props.onUnpinned();
-                });
-            } else if (this.props.onUnpinned) this.props.onUnpinned();
-        }
-    },
-    _canUnpin: function() {
-        return this.props.mxRoom.currentState.mayClientSendStateEvent('m.room.pinned_events', MatrixClientPeg.get());
-    },
-    render: function() {
-        const MessageEvent = sdk.getComponent("views.messages.MessageEvent");
-        const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
-
-        const sender = this.props.mxRoom.getMember(this.props.mxEvent.getSender());
-        const avatarSize = 40;
-
-        let unpinButton = null;
-        if (this._canUnpin()) {
-            unpinButton = <img src="img/cancel-red.svg" className="mx_PinnedEventTile_unpinButton" width="8" height="8"
-                               onClick={this.onUnpinClicked} alt={_t('Unpin Message')} title={_t('Unpin Message')} />;
-        }
-
-        return (
-            <div className="mx_PinnedEventTile">
-                <div className="mx_PinnedEventTile_actions">
-                    <AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
-                        { _t("Jump to message") }
-                    </AccessibleButton>
-                    { unpinButton }
-                </div>
-
-                <MemberAvatar member={sender} width={avatarSize} height={avatarSize} />
-                <span className="mx_PinnedEventTile_sender">
-                    {sender.name}
-                </span>
-                <MessageEvent mxEvent={this.props.mxEvent} className="mx_PinnedEventTile_body" />
-            </div>
-        );
-    }
-});
-
-module.exports = React.createClass({
-    displayName: 'PinnedEventsPanel',
-    propTypes: {
-        // The Room from the js-sdk we're going to show pinned events for
-        room: React.PropTypes.object.isRequired,
-
-        onCancelClick: React.PropTypes.func,
-    },
-
-    getInitialState: function() {
-        return {
-            loading: true,
-        };
-    },
-
-    componentDidMount: function() {
-        this._updatePinnedMessages();
-    },
-
-    _updatePinnedMessages: function() {
-        const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
-        if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
-            this.setState({ loading: false, pinned: [] });
-        } else {
-            const promises = [];
-            const cli = MatrixClientPeg.get();
-
-            pinnedEvents.getContent().pinned.map(eventId => {
-                promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(timeline => {
-                    const event = timeline.getEvents().find(e => e.getId() === eventId);
-                    return {eventId, timeline, event};
-                }).catch(err => {
-                    console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
-                    console.error(err);
-                    return null; // return lack of context to avoid unhandled errors
-                }));
-            });
-
-            Promise.all(promises).then(contexts => {
-                // Filter out the messages before we try to render them
-                const pinned = contexts.filter(context => {
-                    if (!context) return false; // no context == not applicable for the room
-                    if (context.event.getType() !== "m.room.message") return false;
-                    if (context.event.isRedacted()) return false;
-                    return true;
-                });
-
-                this.setState({ loading: false, pinned });
-            });
-        }
-    },
-
-    _getPinnedTiles: function() {
-        if (this.state.pinned.length == 0) {
-            return <div>{ _t("No pinned messages.") }</div>;
-        }
-
-        return this.state.pinned.map(context => {
-            return (<PinnedEventTile key={context.event.getId()} mxRoom={this.props.room} mxEvent={context.event} onUnpinned={this._updatePinnedMessages} />);
-        });
-    },
-
-    render: function() {
-        let tiles = <div>{ _t("Loading...") }</div>;
-        if (this.state && !this.state.loading) {
-            tiles = this._getPinnedTiles();
-        }
-
-        return (
-            <div className="mx_PinnedEventsPanel">
-                <div className="mx_PinnedEventsPanel_body">
-                    <AccessibleButton className="mx_PinnedEventsPanel_cancel" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
-                    <h3 className="mx_PinnedEventsPanel_header">{_t("Pinned Messages")}</h3>
-                    { tiles }
-                </div>
-            </div>
-        );
-    }
-});
diff --git a/src/skins/vector/css/_components.scss b/src/skins/vector/css/_components.scss
index 1d351c82..837b675f 100644
--- a/src/skins/vector/css/_components.scss
+++ b/src/skins/vector/css/_components.scss
@@ -64,6 +64,7 @@
 @import "./matrix-react-sdk/views/voip/_CallView.scss";
 @import "./matrix-react-sdk/views/voip/_IncomingCallbox.scss";
 @import "./matrix-react-sdk/views/voip/_VideoView.scss";
+@import "./matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss";
 @import "./vector-web/_fonts.scss";
 @import "./vector-web/structures/_CompatibilityPage.scss";
 @import "./vector-web/structures/_HomePage.scss";
@@ -88,5 +89,4 @@
 @import "./vector-web/views/rooms/_RoomDropTarget.scss";
 @import "./vector-web/views/rooms/_RoomTooltip.scss";
 @import "./vector-web/views/rooms/_SearchBar.scss";
-@import "./vector-web/views/rooms/_PinnedEventsPanel.scss";
 @import "./vector-web/views/settings/_Notifications.scss";
diff --git a/src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss
similarity index 100%
rename from src/skins/vector/css/vector-web/views/rooms/_PinnedEventsPanel.scss
rename to src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss

From 2d153a72c1351642d6826fe93437d4b4fc5c0273 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 29 Sep 2017 13:33:14 -0600
Subject: [PATCH 14/15] Split up CSS; use .catch instead of .then

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 .../views/context_menus/MessageContextMenu.js |  2 +-
 src/skins/vector/css/_components.scss         |  1 +
 .../views/rooms/_PinnedEventTile.scss         | 67 +++++++++++++++++++
 .../views/rooms/_PinnedEventsPanel.scss       | 51 --------------
 4 files changed, 69 insertions(+), 52 deletions(-)
 create mode 100644 src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventTile.scss

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
index a04bf274..7085e81d 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -135,7 +135,7 @@ module.exports = React.createClass({
 
     onPinClick: function() {
         MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
-            .then(null, e => {
+            .catch(e => {
                 // Intercept the Event Not Found error and fall through the promise chain with no event.
                 if (e.errcode === "M_NOT_FOUND") return null;
                 throw e;
diff --git a/src/skins/vector/css/_components.scss b/src/skins/vector/css/_components.scss
index 837b675f..5c0ae4aa 100644
--- a/src/skins/vector/css/_components.scss
+++ b/src/skins/vector/css/_components.scss
@@ -65,6 +65,7 @@
 @import "./matrix-react-sdk/views/voip/_IncomingCallbox.scss";
 @import "./matrix-react-sdk/views/voip/_VideoView.scss";
 @import "./matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss";
+@import "./matrix-react-sdk/views/rooms/_PinnedEventTile.scss";
 @import "./vector-web/_fonts.scss";
 @import "./vector-web/structures/_CompatibilityPage.scss";
 @import "./vector-web/structures/_HomePage.scss";
diff --git a/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventTile.scss b/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventTile.scss
new file mode 100644
index 00000000..ca790ef8
--- /dev/null
+++ b/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventTile.scss
@@ -0,0 +1,67 @@
+/*
+Copyright 2017 Travis Ralston
+
+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.
+*/
+
+.mx_PinnedEventTile {
+    min-height: 40px;
+    margin-bottom: 5px;
+    width: 100%;
+    border-radius: 5px; // for the hover
+}
+
+.mx_PinnedEventTile:hover {
+    background-color: $event-selected-color;
+}
+
+.mx_PinnedEventTile .mx_PinnedEventTile_sender {
+    color: #868686;
+    font-size: 0.8em;
+    vertical-align: top;
+    display: block;
+    padding-bottom: 3px;
+}
+
+.mx_PinnedEventTile .mx_EventTile_content {
+    margin-left: 50px;
+    position: relative;
+    top: 0;
+    left: 0;
+}
+
+.mx_PinnedEventTile .mx_BaseAvatar {
+    float: left;
+    margin-right: 10px;
+}
+
+.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions {
+    display: block;
+}
+
+.mx_PinnedEventTile_actions {
+    float: right;
+    margin-right: 10px;
+    display: none;
+}
+
+.mx_PinnedEventTile_unpinButton {
+    display: inline-block;
+    cursor: pointer;
+    margin-left: 10px;
+}
+
+.mx_PinnedEventTile_gotoButton {
+    display: inline-block;
+    font-size: 0.8em;
+}
diff --git a/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss b/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss
index 44297cfa..663d5bdf 100644
--- a/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss
+++ b/src/skins/vector/css/matrix-react-sdk/views/rooms/_PinnedEventsPanel.scss
@@ -30,59 +30,8 @@ limitations under the License.
     padding-bottom: 15px;
 }
 
-.mx_PinnedEventTile {
-    min-height: 40px;
-    margin-bottom: 5px;
-    width: 100%;
-    border-radius: 5px; // for the hover
-}
-
-.mx_PinnedEventTile:hover {
-    background-color: $event-selected-color;
-}
-
-.mx_PinnedEventTile .mx_PinnedEventTile_sender {
-    color: #868686;
-    font-size: 0.8em;
-    vertical-align: top;
-    display: block;
-    padding-bottom: 3px;
-}
-
-.mx_PinnedEventTile .mx_EventTile_content {
-    margin-left: 50px;
-    position: relative;
-    top: 0;
-    left: 0;
-}
-
-.mx_PinnedEventTile .mx_BaseAvatar {
-    float: left;
-    margin-right: 10px;
-}
-
 .mx_PinnedEventsPanel_cancel {
     margin: 12px;
     float: right;
     display: inline-block;
 }
-
-.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions {
-    display: block;
-}
-
-.mx_PinnedEventTile_actions {
-    float: right;
-    margin-right: 10px;
-    display: none;
-}
-
-.mx_PinnedEventTile_unpinButton {
-    cursor: pointer;
-    margin-left: 10px;
-}
-
-.mx_PinnedEventTile_gotoButton {
-    display: inline-block;
-    font-size: 0.8em;
-}

From 6926c96f368b73f58fb3dad21f0d5be4aebd560c Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Sat, 14 Oct 2017 16:41:44 -0600
Subject: [PATCH 15/15] Hide pinning messages behind a labs settings

Signed-off-by: Travis Ralston <travpc@gmail.com>
---
 src/components/views/context_menus/MessageContextMenu.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js
index 7085e81d..a07d1162 100644
--- a/src/components/views/context_menus/MessageContextMenu.js
+++ b/src/components/views/context_menus/MessageContextMenu.js
@@ -64,7 +64,10 @@ module.exports = React.createClass({
         const room = cli.getRoom(this.props.mxEvent.getRoomId());
 
         const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
-        const canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
+        let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
+
+        // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
+        if (!UserSettingsStore.isFeatureEnabled("feature_pinning")) canPin = false;
 
         this.setState({canRedact, canPin});
     },