diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss
index 815e8408b5..2f01f3ecc6 100644
--- a/res/css/views/dialogs/_DevtoolsDialog.scss
+++ b/res/css/views/dialogs/_DevtoolsDialog.scss
@@ -18,7 +18,12 @@ limitations under the License.
     margin: 10px 0;
 }
 
-.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_RoomStateExplorer_query {
+.mx_DevTools_ServersInRoomList_button {
+    /* Set the cursor back to default as `.mx_Dialog button` sets it to pointer */
+    cursor: default !important;
+}
+
+.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query {
     margin-bottom: 10px;
     width: 100%;
 }
diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss
index 49e3930979..3c6d019b30 100644
--- a/res/css/views/messages/_ReactionsRowButton.scss
+++ b/res/css/views/messages/_ReactionsRowButton.scss
@@ -24,6 +24,7 @@ limitations under the License.
     border-radius: 10px;
     background-color: $reaction-row-button-bg-color;
     cursor: pointer;
+    user-select: none;
 
     &:hover {
         border-color: $reaction-row-button-hover-border-color;
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index 763eddbd5d..cd40c7874e 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -175,6 +175,8 @@ class MatrixClientPeg {
     }
 
     _createClient(creds: MatrixClientCreds) {
+        const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
+
         const opts = {
             baseUrl: creds.homeserverUrl,
             idBaseUrl: creds.identityServerUrl,
@@ -183,7 +185,8 @@ class MatrixClientPeg {
             deviceId: creds.deviceId,
             timelineSupport: true,
             forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
-            verificationMethods: [verificationMethods.SAS]
+            verificationMethods: [verificationMethods.SAS],
+            unstableClientRelationAggregation: aggregateRelations,
         };
 
         this.matrixClient = createMatrixClient(opts);
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
index f72ba1e005..55107db899 100644
--- a/src/SlashCommands.js
+++ b/src/SlashCommands.js
@@ -1,6 +1,7 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
 Copyright 2018 New Vector Ltd
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -30,6 +31,8 @@ import MultiInviter from './utils/MultiInviter';
 import { linkifyAndSanitizeHtml } from './HtmlUtils';
 import QuestionDialog from "./components/views/dialogs/QuestionDialog";
 import WidgetUtils from "./utils/WidgetUtils";
+import {textToHtmlRainbow} from "./utils/colour";
+import Promise from "bluebird";
 
 class Command {
     constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
@@ -190,8 +193,8 @@ export const CommandMap = {
         },
     }),
 
-    roomnick: new Command({
-        name: 'roomnick',
+    myroomnick: new Command({
+        name: 'myroomnick',
         args: '<display_name>',
         description: _td('Changes your display nickname in the current room only'),
         runFn: function(roomId, args) {
@@ -208,6 +211,47 @@ export const CommandMap = {
         },
     }),
 
+    myroomavatar: new Command({
+        name: 'myroomavatar',
+        args: '[<mxc_url>]',
+        description: _td('Changes your avatar in this current room only'),
+        runFn: function(roomId, args) {
+            const cli = MatrixClientPeg.get();
+            const room = cli.getRoom(roomId);
+            const userId = cli.getUserId();
+
+            let promise = Promise.resolve(args);
+            if (!args) {
+                promise = new Promise((resolve) => {
+                    const fileSelector = document.createElement('input');
+                    fileSelector.setAttribute('type', 'file');
+                    fileSelector.onchange = (ev) => {
+                        const file = ev.target.files[0];
+
+                        const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
+                        Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
+                            file,
+                            onFinished: (shouldContinue) => {
+                                if (shouldContinue) resolve(cli.uploadContent(file));
+                            },
+                        });
+                    };
+
+                    fileSelector.click();
+                });
+            }
+
+            return success(promise.then((url) => {
+                const ev = room.currentState.getStateEvents('m.room.member', userId);
+                const content = {
+                    ...ev ? ev.getContent() : { membership: 'join' },
+                    avatar_url: url,
+                };
+                return cli.sendStateEvent(roomId, 'm.room.member', content, userId);
+            }));
+        },
+    }),
+
     tint: new Command({
         name: 'tint',
         args: '<color1> [<color2>]',
@@ -718,6 +762,26 @@ export const CommandMap = {
             return success();
         },
     }),
+
+    rainbow: new Command({
+        name: "rainbow",
+        description: _td("Sends the given message coloured as a rainbow"),
+        args: '<message>',
+        runFn: function(roomId, args) {
+            if (!args) return reject(this.getUserId());
+            return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
+        },
+    }),
+
+    rainbowme: new Command({
+        name: "rainbowme",
+        description: _td("Sends the given emote coloured as a rainbow"),
+        args: '<message>',
+        runFn: function(roomId, args) {
+            if (!args) return reject(this.getUserId());
+            return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
+        },
+    }),
 };
 /* eslint-enable babel/no-invalid-this */
 
@@ -727,6 +791,7 @@ const aliases = {
     j: "join",
     newballsplease: "discardsession",
     goto: "join", // because it handles event permalinks magically
+    roomnick: "myroomnick",
 };
 
 
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index b57b659136..2037217710 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -92,6 +92,9 @@ module.exports = React.createClass({
 
         // show timestamps always
         alwaysShowTimestamps: PropTypes.bool,
+
+        // helper function to access relations for an event
+        getRelationsForEvent: PropTypes.func,
     },
 
     componentWillMount: function() {
@@ -511,22 +514,27 @@ module.exports = React.createClass({
             readReceipts = this._getReadReceiptsForEvent(mxEv);
         }
         ret.push(
-                <li key={eventId}
-                        ref={this._collectEventNode.bind(this, eventId)}
-                        data-scroll-tokens={scrollToken}>
-                    <EventTile mxEvent={mxEv} continuation={continuation}
-                        isRedacted={mxEv.isRedacted()}
-                        onHeightChanged={this._onHeightChanged}
-                        readReceipts={readReceipts}
-                        readReceiptMap={this._readReceiptMap}
-                        showUrlPreview={this.props.showUrlPreview}
-                        checkUnmounting={this._isUnmounting}
-                        eventSendStatus={mxEv.status}
-                        tileShape={this.props.tileShape}
-                        isTwelveHour={this.props.isTwelveHour}
-                        permalinkCreator={this.props.permalinkCreator}
-                        last={last} isSelectedEvent={highlight} />
-                </li>,
+            <li key={eventId}
+                ref={this._collectEventNode.bind(this, eventId)}
+                data-scroll-tokens={scrollToken}
+            >
+                <EventTile mxEvent={mxEv}
+                    continuation={continuation}
+                    isRedacted={mxEv.isRedacted()}
+                    onHeightChanged={this._onHeightChanged}
+                    readReceipts={readReceipts}
+                    readReceiptMap={this._readReceiptMap}
+                    showUrlPreview={this.props.showUrlPreview}
+                    checkUnmounting={this._isUnmounting}
+                    eventSendStatus={mxEv.status}
+                    tileShape={this.props.tileShape}
+                    isTwelveHour={this.props.isTwelveHour}
+                    permalinkCreator={this.props.permalinkCreator}
+                    last={last}
+                    isSelectedEvent={highlight}
+                    getRelationsForEvent={this.props.getRelationsForEvent}
+                />
+            </li>,
         );
 
         return ret;
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index aa278f2349..17a062be98 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -1168,6 +1168,10 @@ const TimelinePanel = React.createClass({
         });
     },
 
+    getRelationsForEvent(...args) {
+        return this.props.timelineSet.getRelationsForEvent(...args);
+    },
+
     render: function() {
         const MessagePanel = sdk.getComponent("structures.MessagePanel");
         const Loader = sdk.getComponent("elements.Spinner");
@@ -1193,9 +1197,9 @@ const TimelinePanel = React.createClass({
 
         if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
             return (
-                    <div className={this.props.className + " mx_RoomView_messageListWrapper"}>
-                        <div className="mx_RoomView_empty">{ this.props.empty }</div>
-                    </div>
+                <div className={this.props.className + " mx_RoomView_messageListWrapper"}>
+                    <div className="mx_RoomView_empty">{this.props.empty}</div>
+                </div>
             );
         }
 
@@ -1217,28 +1221,29 @@ const TimelinePanel = React.createClass({
         );
         return (
             <MessagePanel ref="messagePanel"
-                          room={this.props.timelineSet.room}
-                          permalinkCreator={this.props.permalinkCreator}
-                          hidden={this.props.hidden}
-                          backPaginating={this.state.backPaginating}
-                          forwardPaginating={forwardPaginating}
-                          events={this.state.events}
-                          highlightedEventId={this.props.highlightedEventId}
-                          readMarkerEventId={this.state.readMarkerEventId}
-                          readMarkerVisible={this.state.readMarkerVisible}
-                          suppressFirstDateSeparator={this.state.canBackPaginate}
-                          showUrlPreview={this.props.showUrlPreview}
-                          showReadReceipts={this.props.showReadReceipts}
-                          ourUserId={MatrixClientPeg.get().credentials.userId}
-                          stickyBottom={stickyBottom}
-                          onScroll={this.onMessageListScroll}
-                          onFillRequest={this.onMessageListFillRequest}
-                          onUnfillRequest={this.onMessageListUnfillRequest}
-                          isTwelveHour={this.state.isTwelveHour}
-                          alwaysShowTimestamps={this.state.alwaysShowTimestamps}
-                          className={this.props.className}
-                          tileShape={this.props.tileShape}
-                          resizeNotifier={this.props.resizeNotifier}
+                room={this.props.timelineSet.room}
+                permalinkCreator={this.props.permalinkCreator}
+                hidden={this.props.hidden}
+                backPaginating={this.state.backPaginating}
+                forwardPaginating={forwardPaginating}
+                events={this.state.events}
+                highlightedEventId={this.props.highlightedEventId}
+                readMarkerEventId={this.state.readMarkerEventId}
+                readMarkerVisible={this.state.readMarkerVisible}
+                suppressFirstDateSeparator={this.state.canBackPaginate}
+                showUrlPreview={this.props.showUrlPreview}
+                showReadReceipts={this.props.showReadReceipts}
+                ourUserId={MatrixClientPeg.get().credentials.userId}
+                stickyBottom={stickyBottom}
+                onScroll={this.onMessageListScroll}
+                onFillRequest={this.onMessageListFillRequest}
+                onUnfillRequest={this.onMessageListUnfillRequest}
+                isTwelveHour={this.state.isTwelveHour}
+                alwaysShowTimestamps={this.state.alwaysShowTimestamps}
+                className={this.props.className}
+                tileShape={this.props.tileShape}
+                resizeNotifier={this.props.resizeNotifier}
+                getRelationsForEvent={this.getRelationsForEvent}
             />
         );
     },
diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js
index e192419448..0eecc6b826 100644
--- a/src/components/views/auth/RegistrationForm.js
+++ b/src/components/views/auth/RegistrationForm.js
@@ -1,7 +1,7 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
 Copyright 2017 Vector Creations Ltd
-Copyright 2018 New Vector Ltd
+Copyright 2018, 2019 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -73,6 +73,7 @@ module.exports = React.createClass({
             password: "",
             passwordConfirm: "",
             passwordComplexity: null,
+            passwordSafe: false,
         };
     },
 
@@ -147,7 +148,11 @@ module.exports = React.createClass({
             if (!field) {
                 continue;
             }
-            field.validate({ allowEmpty: false });
+            // We must wait for these validations to finish before queueing
+            // up the setState below so our setState goes in the queue after
+            // all the setStates from these validate calls (that's how we
+            // know they've finished).
+            await field.validate({ allowEmpty: false });
         }
 
         // Validation and state updates are async, so we need to wait for them to complete
@@ -267,12 +272,23 @@ module.exports = React.createClass({
                     }
                     const { scorePassword } = await import('../../../utils/PasswordScorer');
                     const complexity = scorePassword(value);
+                    const safe = complexity.score >= PASSWORD_MIN_SCORE;
+                    const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
                     this.setState({
                         passwordComplexity: complexity,
+                        passwordSafe: safe,
                     });
-                    return complexity.score >= PASSWORD_MIN_SCORE;
+                    return allowUnsafe || safe;
+                },
+                valid: function() {
+                    // Unsafe passwords that are valid are only possible through a
+                    // configuration flag. We'll print some helper text to signal
+                    // to the user that their password is allowed, but unsafe.
+                    if (!this.state.passwordSafe) {
+                        return _t("Password is allowed, but unsafe");
+                    }
+                    return _t("Nice, strong password!");
                 },
-                valid: () => _t("Nice, strong password!"),
                 invalid: function() {
                     const complexity = this.state.passwordComplexity;
                     if (!complexity) {
diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js
index 3db516a74d..0835c41bb9 100644
--- a/src/components/views/dialogs/DevtoolsDialog.js
+++ b/src/components/views/dialogs/DevtoolsDialog.js
@@ -551,11 +551,53 @@ class AccountDataExplorer extends DevtoolsComponent {
     }
 }
 
+class ServersInRoomList extends DevtoolsComponent {
+    static getLabel() { return _t('View Servers in Room'); }
+
+    static propTypes = {
+        onBack: PropTypes.func.isRequired,
+    };
+
+    constructor(props, context) {
+        super(props, context);
+
+        const room = MatrixClientPeg.get().getRoom(this.context.roomId);
+        const servers = new Set();
+        room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
+        this.servers = Array.from(servers).map(s =>
+            <button key={s} className="mx_DevTools_ServersInRoomList_button">
+                { s }
+            </button>);
+
+        this.state = {
+            query: '',
+        };
+    }
+
+    onQuery = (query) => {
+        this.setState({ query });
+    }
+
+    render() {
+        return <div>
+            <div className="mx_Dialog_content">
+                <FilteredList query={this.state.query} onChange={this.onQuery}>
+                    { this.servers }
+                </FilteredList>
+            </div>
+            <div className="mx_Dialog_buttons">
+                <button onClick={this.props.onBack}>{ _t('Back') }</button>
+            </div>
+        </div>;
+    }
+}
+
 const Entries = [
     SendCustomEvent,
     RoomStateExplorer,
     SendAccountData,
     AccountDataExplorer,
+    ServersInRoomList,
 ];
 
 export default class DevtoolsDialog extends React.Component {
diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js
index 05ed262078..c221289ff3 100644
--- a/src/components/views/dialogs/RoomSettingsDialog.js
+++ b/src/components/views/dialogs/RoomSettingsDialog.js
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 New Vector Ltd
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -24,6 +25,7 @@ import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab
 import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab";
 import sdk from "../../../index";
 import MatrixClientPeg from "../../../MatrixClientPeg";
+import dis from "../../../dispatcher";
 
 export default class RoomSettingsDialog extends React.Component {
     static propTypes = {
@@ -31,6 +33,22 @@ export default class RoomSettingsDialog extends React.Component {
         onFinished: PropTypes.func.isRequired,
     };
 
+    componentWillMount() {
+        this._dispatcherRef = dis.register(this._onAction);
+    }
+
+    componentWillUnmount() {
+        dis.unregister(this._dispatcherRef);
+    }
+
+    _onAction = (payload) => {
+        // When room changes below us, close the room settings
+        // whilst the modal is open this can only be triggered when someone hits Leave Room
+        if (payload.action === 'view_next_room') {
+            this.props.onFinished();
+        }
+    };
+
     _getTabs() {
         const tabs = [];
 
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 9a482c9e6e..52630d7b0e 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -28,6 +28,8 @@ import { isContentActionable } from '../../../utils/EventUtils';
 export default class MessageActionBar extends React.PureComponent {
     static propTypes = {
         mxEvent: PropTypes.object.isRequired,
+        // The Relations model from the JS SDK for reactions to `mxEvent`
+        reactions: PropTypes.object,
         permalinkCreator: PropTypes.object,
         getTile: PropTypes.func,
         getReplyThread: PropTypes.func,
@@ -100,19 +102,11 @@ export default class MessageActionBar extends React.PureComponent {
         }
 
         const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
-        const options = [
-            {
-                key: "agree",
-                content: "👍",
-            },
-            {
-                key: "disagree",
-                content: "👎",
-            },
-        ];
         return <ReactionDimension
             title={_t("Agree or Disagree")}
-            options={options}
+            options={["👍", "👎"]}
+            reactions={this.props.reactions}
+            mxEvent={this.props.mxEvent}
         />;
     }
 
@@ -122,19 +116,11 @@ export default class MessageActionBar extends React.PureComponent {
         }
 
         const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
-        const options = [
-            {
-                key: "like",
-                content: "🙂",
-            },
-            {
-                key: "dislike",
-                content: "😔",
-            },
-        ];
         return <ReactionDimension
             title={_t("Like or Dislike")}
-            options={options}
+            options={["🙂", "😔"]}
+            reactions={this.props.reactions}
+            mxEvent={this.props.mxEvent}
         />;
     }
 
diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js
index 3b72aabe15..a0cf5a86ec 100644
--- a/src/components/views/messages/ReactionDimension.js
+++ b/src/components/views/messages/ReactionDimension.js
@@ -18,49 +18,141 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 
+import MatrixClientPeg from '../../../MatrixClientPeg';
+
 export default class ReactionDimension extends React.PureComponent {
     static propTypes = {
+        mxEvent: PropTypes.object.isRequired,
+        // Array of strings containing the emoji for each option
         options: PropTypes.array.isRequired,
         title: PropTypes.string,
+        // The Relations model from the JS SDK for reactions
+        reactions: PropTypes.object,
     };
 
     constructor(props) {
         super(props);
 
-        this.state = {
-            selected: null,
-        };
+        this.state = this.getSelection();
+
+        if (props.reactions) {
+            props.reactions.on("Relations.add", this.onReactionsChange);
+            props.reactions.on("Relations.redaction", this.onReactionsChange);
+        }
+    }
+
+    componentDidUpdate(prevProps) {
+        if (prevProps.reactions !== this.props.reactions) {
+            this.props.reactions.on("Relations.add", this.onReactionsChange);
+            this.props.reactions.on("Relations.redaction", this.onReactionsChange);
+            this.onReactionsChange();
+        }
+    }
+
+    componentWillUnmount() {
+        if (this.props.reactions) {
+            this.props.reactions.removeListener(
+                "Relations.add",
+                this.onReactionsChange,
+            );
+            this.props.reactions.removeListener(
+                "Relations.redaction",
+                this.onReactionsChange,
+            );
+        }
+    }
+
+    onReactionsChange = () => {
+        this.setState(this.getSelection());
+    }
+
+    getSelection() {
+        const myReactions = this.getMyReactions();
+        if (!myReactions) {
+            return {
+                selectedOption: null,
+                selectedReactionEvent: null,
+            };
+        }
+        const { options } = this.props;
+        let selectedOption = null;
+        let selectedReactionEvent = null;
+        for (const option of options) {
+            const reactionForOption = myReactions.find(mxEvent => {
+                if (mxEvent.isRedacted()) {
+                    return false;
+                }
+                return mxEvent.getContent()["m.relates_to"].key === option;
+            });
+            if (!reactionForOption) {
+                continue;
+            }
+            if (selectedOption) {
+                // If there are multiple selected values (only expected to occur via
+                // non-Riot clients), then act as if none are selected.
+                return {
+                    selectedOption: null,
+                    selectedReactionEvent: null,
+                };
+            }
+            selectedOption = option;
+            selectedReactionEvent = reactionForOption;
+        }
+        return { selectedOption, selectedReactionEvent };
+    }
+
+    getMyReactions() {
+        const reactions = this.props.reactions;
+        if (!reactions) {
+            return null;
+        }
+        const userId = MatrixClientPeg.get().getUserId();
+        return reactions.getAnnotationsBySender()[userId];
     }
 
     onOptionClick = (ev) => {
         const { key } = ev.target.dataset;
-        this.toggleDimensionValue(key);
+        this.toggleDimension(key);
     }
 
-    toggleDimensionValue(value) {
-        const state = this.state.selected;
-        const newState = state !== value ? value : null;
+    toggleDimension(key) {
+        const { selectedOption, selectedReactionEvent } = this.state;
+        const newSelectedOption = selectedOption !== key ? key : null;
         this.setState({
-            selected: newState,
+            selectedOption: newSelectedOption,
         });
-        // TODO: Send the reaction event
+        if (selectedReactionEvent) {
+            MatrixClientPeg.get().redactEvent(
+                this.props.mxEvent.getRoomId(),
+                selectedReactionEvent.getId(),
+            );
+        }
+        if (newSelectedOption) {
+            MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), "m.reaction", {
+                "m.relates_to": {
+                    "rel_type": "m.annotation",
+                    "event_id": this.props.mxEvent.getId(),
+                    "key": newSelectedOption,
+                },
+            });
+        }
     }
 
     render() {
-        const { selected } = this.state;
+        const { selectedOption } = this.state;
         const { options } = this.props;
 
         const items = options.map(option => {
-            const disabled = selected && selected !== option.key;
+            const disabled = selectedOption && selectedOption !== option;
             const classes = classNames({
                 mx_ReactionDimension_disabled: disabled,
             });
-            return <span key={option.key}
-                data-key={option.key}
+            return <span key={option}
+                data-key={option}
                 className={classes}
                 onClick={this.onOptionClick}
             >
-                {option.content}
+                {option}
             </span>;
         });
 
diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js
index a4299b9853..ffb81e1a38 100644
--- a/src/components/views/messages/ReactionsRow.js
+++ b/src/components/views/messages/ReactionsRow.js
@@ -19,42 +19,96 @@ import PropTypes from 'prop-types';
 
 import sdk from '../../../index';
 import { isContentActionable } from '../../../utils/EventUtils';
-
-// TODO: Actually load reactions from the timeline
-// Since we don't yet load reactions, let's inject some dummy data for testing the UI
-// only. The UI assumes these are already sorted into the order we want to present,
-// presumably highest vote first.
-const SAMPLE_REACTIONS = {
-    "👍": 4,
-    "👎": 2,
-    "🙂": 1,
-};
+import MatrixClientPeg from '../../../MatrixClientPeg';
 
 export default class ReactionsRow extends React.PureComponent {
     static propTypes = {
         // The event we're displaying reactions for
         mxEvent: PropTypes.object.isRequired,
+        // The Relations model from the JS SDK for reactions to `mxEvent`
+        reactions: PropTypes.object,
+    }
+
+    constructor(props) {
+        super(props);
+
+        if (props.reactions) {
+            props.reactions.on("Relations.add", this.onReactionsChange);
+            props.reactions.on("Relations.redaction", this.onReactionsChange);
+        }
+
+        this.state = {
+            myReactions: this.getMyReactions(),
+        };
+    }
+
+    componentDidUpdate(prevProps) {
+        if (prevProps.reactions !== this.props.reactions) {
+            this.props.reactions.on("Relations.add", this.onReactionsChange);
+            this.props.reactions.on("Relations.redaction", this.onReactionsChange);
+            this.onReactionsChange();
+        }
+    }
+
+    componentWillUnmount() {
+        if (this.props.reactions) {
+            this.props.reactions.removeListener(
+                "Relations.add",
+                this.onReactionsChange,
+            );
+            this.props.reactions.removeListener(
+                "Relations.redaction",
+                this.onReactionsChange,
+            );
+        }
+    }
+
+    onReactionsChange = () => {
+        // TODO: Call `onHeightChanged` as needed
+        this.setState({
+            myReactions: this.getMyReactions(),
+        });
+        // Using `forceUpdate` for the moment, since we know the overall set of reactions
+        // has changed (this is triggered by events for that purpose only) and
+        // `PureComponent`s shallow state / props compare would otherwise filter this out.
+        this.forceUpdate();
+    }
+
+    getMyReactions() {
+        const reactions = this.props.reactions;
+        if (!reactions) {
+            return null;
+        }
+        const userId = MatrixClientPeg.get().getUserId();
+        return reactions.getAnnotationsBySender()[userId];
     }
 
     render() {
-        const { mxEvent } = this.props;
+        const { mxEvent, reactions } = this.props;
+        const { myReactions } = this.state;
 
-        if (!isContentActionable(mxEvent)) {
-            return null;
-        }
-
-        const content = mxEvent.getContent();
-        // TODO: Remove this once we load real reactions
-        if (!content.body || content.body !== "reactions test") {
+        if (!reactions || !isContentActionable(mxEvent)) {
             return null;
         }
 
         const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
-        const items = Object.entries(SAMPLE_REACTIONS).map(([content, count]) => {
+        const items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
+            const count = events.size;
+            if (!count) {
+                return null;
+            }
+            const myReactionEvent = myReactions && myReactions.find(mxEvent => {
+                if (mxEvent.isRedacted()) {
+                    return false;
+                }
+                return mxEvent.getContent()["m.relates_to"].key === content;
+            });
             return <ReactionsRowButton
                 key={content}
                 content={content}
                 count={count}
+                mxEvent={mxEvent}
+                myReactionEvent={myReactionEvent}
             />;
         });
 
diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.js
index 985479a237..721147cdb8 100644
--- a/src/components/views/messages/ReactionsRowButton.js
+++ b/src/components/views/messages/ReactionsRowButton.js
@@ -18,48 +18,48 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 
+import MatrixClientPeg from '../../../MatrixClientPeg';
+
 export default class ReactionsRowButton extends React.PureComponent {
     static propTypes = {
+        // The event we're displaying reactions for
+        mxEvent: PropTypes.object.isRequired,
         content: PropTypes.string.isRequired,
         count: PropTypes.number.isRequired,
-    }
-
-    constructor(props) {
-        super(props);
-
-        // TODO: This should be derived from actual reactions you may have sent
-        // once we have some API to read them.
-        this.state = {
-            selected: false,
-        };
+        // A possible Matrix event if the current user has voted for this type
+        myReactionEvent: PropTypes.object,
     }
 
     onClick = (ev) => {
-        const state = this.state.selected;
-        this.setState({
-            selected: !state,
-        });
-        // TODO: Send the reaction event
+        const { mxEvent, myReactionEvent, content } = this.props;
+        if (myReactionEvent) {
+            MatrixClientPeg.get().redactEvent(
+                mxEvent.getRoomId(),
+                myReactionEvent.getId(),
+            );
+        } else {
+            MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
+                "m.relates_to": {
+                    "rel_type": "m.annotation",
+                    "event_id": mxEvent.getId(),
+                    "key": content,
+                },
+            });
+        }
     };
 
     render() {
-        const { content, count } = this.props;
-        const { selected } = this.state;
+        const { content, count, myReactionEvent } = this.props;
 
         const classes = classNames({
             mx_ReactionsRowButton: true,
-            mx_ReactionsRowButton_selected: selected,
+            mx_ReactionsRowButton_selected: !!myReactionEvent,
         });
 
-        let adjustedCount = count;
-        if (selected) {
-            adjustedCount++;
-        }
-
         return <span className={classes}
             onClick={this.onClick}
         >
-            {content} {adjustedCount}
+            {content} {count}
         </span>;
     }
 }
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 59025bf431..1706019e94 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -159,6 +159,9 @@ module.exports = withMatrixClient(React.createClass({
 
         // show twelve hour timestamps
         isTwelveHour: PropTypes.bool,
+
+        // helper function to access relations for an event
+        getRelationsForEvent: PropTypes.func,
     },
 
     getDefaultProps: function() {
@@ -179,6 +182,8 @@ module.exports = withMatrixClient(React.createClass({
             verified: null,
             // Whether onRequestKeysClick has been called since mounting.
             previouslyRequestedKeys: false,
+            // The Relations model from the JS SDK for reactions to `mxEvent`
+            reactions: this.getReactions(),
         };
     },
 
@@ -190,9 +195,12 @@ module.exports = withMatrixClient(React.createClass({
 
     componentDidMount: function() {
         this._suppressReadReceiptAnimation = false;
-        this.props.matrixClient.on("deviceVerificationChanged",
-                                 this.onDeviceVerificationChanged);
+        const client = this.props.matrixClient;
+        client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
         this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
+        if (SettingsStore.isFeatureEnabled("feature_reactions")) {
+            this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
+        }
     },
 
     componentWillReceiveProps: function(nextProps) {
@@ -215,6 +223,9 @@ module.exports = withMatrixClient(React.createClass({
         const client = this.props.matrixClient;
         client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
         this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
+        if (SettingsStore.isFeatureEnabled("feature_reactions")) {
+            this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
+        }
     },
 
     /** called when the event is decrypted after we show it.
@@ -472,6 +483,27 @@ module.exports = withMatrixClient(React.createClass({
         return this.refs.replyThread;
     },
 
+    getReactions() {
+        if (
+            !this.props.getRelationsForEvent ||
+            !SettingsStore.isFeatureEnabled("feature_reactions")
+        ) {
+            return null;
+        }
+        const eventId = this.props.mxEvent.getId();
+        return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
+    },
+
+    _onReactionsCreated(relationType, eventType) {
+        if (relationType !== "m.annotation" || eventType !== "m.reaction") {
+            return;
+        }
+        this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
+        this.setState({
+            reactions: this.getReactions(),
+        });
+    },
+
     render: function() {
         const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
         const SenderProfile = sdk.getComponent('messages.SenderProfile');
@@ -587,6 +619,7 @@ module.exports = withMatrixClient(React.createClass({
         const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
         const actionBar = <MessageActionBar
             mxEvent={this.props.mxEvent}
+            reactions={this.state.reactions}
             permalinkCreator={this.props.permalinkCreator}
             getTile={this.getTile}
             getReplyThread={this.getReplyThread}
@@ -630,11 +663,12 @@ module.exports = withMatrixClient(React.createClass({
                 <ToolTipButton helpText={keyRequestHelpText} />
             </div> : null;
 
-        let reactions;
+        let reactionsRow;
         if (SettingsStore.isFeatureEnabled("feature_reactions")) {
             const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
-            reactions = <ReactionsRow
+            reactionsRow = <ReactionsRow
                 mxEvent={this.props.mxEvent}
+                reactions={this.state.reactions}
             />;
         }
 
@@ -750,7 +784,7 @@ module.exports = withMatrixClient(React.createClass({
                                            showUrlPreview={this.props.showUrlPreview}
                                            onHeightChanged={this.props.onHeightChanged} />
                             { keyRequestInfo }
-                            { reactions }
+                            { reactionsRow }
                             { actionBar }
                         </div>
                         {
diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js
index 35161dedf7..be10a3900f 100644
--- a/src/components/views/rooms/MemberInfo.js
+++ b/src/components/views/rooms/MemberInfo.js
@@ -735,8 +735,8 @@ module.exports = withMatrixClient(React.createClass({
                         // we're only inviting one user.
                         const inviter = new MultiInviter(roomId);
                         await inviter.invite([member.userId]).then(() => {
-                            if (inviter.getCompletionState(userId) !== "invited")
-                                throw new Error(inviter.getErrorText(userId));
+                            if (inviter.getCompletionState(member.userId) !== "invited")
+                                throw new Error(inviter.getErrorText(member.userId));
                         });
                     } catch (err) {
                         const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 35593708c8..dfc391b164 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -146,6 +146,7 @@
     "Upgrade": "Upgrade",
     "Changes your display nickname": "Changes your display nickname",
     "Changes your display nickname in the current room only": "Changes your display nickname in the current room only",
+    "Changes your avatar in this current room only": "Changes your avatar in this current room only",
     "Changes colour scheme of current room": "Changes colour scheme of current room",
     "Gets or sets the room topic": "Gets or sets the room topic",
     "This room has no topic.": "This room has no topic.",
@@ -178,6 +179,8 @@
     "The signing key you provided matches the signing key you received from %(userId)s's device %(deviceId)s. Device marked as verified.": "The signing key you provided matches the signing key you received from %(userId)s's device %(deviceId)s. Device marked as verified.",
     "Displays action": "Displays action",
     "Forces the current outbound group session in an encrypted room to be discarded": "Forces the current outbound group session in an encrypted room to be discarded",
+    "Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow",
+    "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow",
     "Unrecognised command:": "Unrecognised command:",
     "Reason": "Reason",
     "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
@@ -299,7 +302,7 @@
     "Show recent room avatars above the room list": "Show recent room avatars above the room list",
     "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
     "Render simple counters in room header": "Render simple counters in room header",
-    "React to messages with emoji": "React to messages with emoji",
+    "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)",
     "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
     "Use compact timeline layout": "Use compact timeline layout",
     "Show a placeholder for removed messages": "Show a placeholder for removed messages",
@@ -1135,6 +1138,7 @@
     "Filter results": "Filter results",
     "Explore Room State": "Explore Room State",
     "Explore Account Data": "Explore Account Data",
+    "View Servers in Room": "View Servers in Room",
     "Toolbox": "Toolbox",
     "Developer Tools": "Developer Tools",
     "An error has occurred.": "An error has occurred.",
@@ -1330,6 +1334,7 @@
     "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)",
     "Doesn't look like a valid email address": "Doesn't look like a valid email address",
     "Enter password": "Enter password",
+    "Password is allowed, but unsafe": "Password is allowed, but unsafe",
     "Nice, strong password!": "Nice, strong password!",
     "Keep going...": "Keep going...",
     "Passwords don't match": "Passwords don't match",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index 4be1b67227..1c3ca4fd0f 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -120,7 +120,7 @@ export const SETTINGS = {
     },
     "feature_reactions": {
         isFeature: true,
-        displayName: _td("React to messages with emoji"),
+        displayName: _td("React to messages with emoji (refresh to apply changes)"),
         supportedLevels: LEVELS_FEATURE,
         default: false,
     },
diff --git a/src/utils/colour.js b/src/utils/colour.js
new file mode 100644
index 0000000000..5d90bf0773
--- /dev/null
+++ b/src/utils/colour.js
@@ -0,0 +1,67 @@
+/*
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+
+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.
+*/
+
+export function hueToRGB(h, s, l) {
+    const c = s * (1 - Math.abs(2 * l - 1));
+    const x = c * (1 - Math.abs((h / 60) % 2 - 1));
+    const m = l - c / 2;
+
+    let r = 0;
+    let g = 0;
+    let b = 0;
+
+    if (0 <= h && h < 60) {
+        r = c;
+        g = x;
+        b = 0;
+    } else if (60 <= h && h < 120) {
+        r = x;
+        g = c;
+        b = 0;
+    } else if (120 <= h && h < 180) {
+        r = 0;
+        g = c;
+        b = x;
+    } else if (180 <= h && h < 240) {
+        r = 0;
+        g = x;
+        b = c;
+    } else if (240 <= h && h < 300) {
+        r = x;
+        g = 0;
+        b = c;
+    } else if (300 <= h && h < 360) {
+        r = c;
+        g = 0;
+        b = x;
+    }
+
+    return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
+}
+
+
+export function textToHtmlRainbow(str) {
+    const frequency = 360 / str.length;
+
+    return str.split("").map((c, i) => {
+        const [r, g, b] = hueToRGB(i * frequency, 1.0, 0.5);
+        return '<font color="#' +
+            r.toString(16).padStart(2, "0") +
+            g.toString(16).padStart(2, "0") +
+            b.toString(16).padStart(2, "0") +
+            '">' + c + '</font>';
+    }).join("");
+}