diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js
index b306eab23c..0c262fe89a 100644
--- a/src/HtmlUtils.js
+++ b/src/HtmlUtils.js
@@ -208,7 +208,7 @@ const sanitizeHtmlParams = {
             // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
             // because transformTags is used _before_ we filter by allowedSchemesByTag and
             // we don't want to allow images with `https?` `src`s.
-            if (!attribs.src.startsWith('mxc://')) {
+            if (!attribs.src || !attribs.src.startsWith('mxc://')) {
                 return { tagName, attribs: {}};
             }
             attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
diff --git a/src/UnknownDeviceErrorHandler.js b/src/UnknownDeviceErrorHandler.js
index e7d77b3b66..664fe14eb5 100644
--- a/src/UnknownDeviceErrorHandler.js
+++ b/src/UnknownDeviceErrorHandler.js
@@ -25,7 +25,6 @@ const onAction = function(payload) {
         const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
         isDialogOpen = true;
         Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
-            devices: payload.err.devices,
             room: payload.room,
             onFinished: (r) => {
                 isDialogOpen = false;
diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js
index 94d2ed28de..3d30363d9f 100644
--- a/src/autocomplete/Autocompleter.js
+++ b/src/autocomplete/Autocompleter.js
@@ -23,6 +23,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider';
 import RoomProvider from './RoomProvider';
 import UserProvider from './UserProvider';
 import EmojiProvider from './EmojiProvider';
+import NotifProvider from './NotifProvider';
 import Promise from 'bluebird';
 
 export type SelectionRange = {
@@ -44,6 +45,7 @@ const PROVIDERS = [
     UserProvider,
     RoomProvider,
     EmojiProvider,
+    NotifProvider,
     CommandProvider,
     DuckDuckGoProvider,
 ];
diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.js
new file mode 100644
index 0000000000..b7ac645525
--- /dev/null
+++ b/src/autocomplete/NotifProvider.js
@@ -0,0 +1,62 @@
+/*
+Copyright 2017 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.
+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.
+*/
+
+import React from 'react';
+import AutocompleteProvider from './AutocompleteProvider';
+import { _t } from '../languageHandler';
+import MatrixClientPeg from '../MatrixClientPeg';
+import {PillCompletion} from './Components';
+import sdk from '../index';
+
+const AT_ROOM_REGEX = /@\S*/g;
+
+export default class NotifProvider extends AutocompleteProvider {
+    constructor(room) {
+        super(AT_ROOM_REGEX);
+        this.room = room;
+    }
+
+    async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
+        const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
+
+        const client = MatrixClientPeg.get();
+
+        if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return [];
+
+        const {command, range} = this.getCurrentCommand(query, selection, force);
+        if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
+            return [{
+                completion: '@room',
+                suffix: ' ',
+                component: (
+                    <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
+                ),
+                range,
+            }];
+        }
+        return [];
+    }
+
+    getName() {
+        return '❗️ ' + _t('Room Notification');
+    }
+
+    renderCompletions(completions: [React.Component]): ?React.Component {
+        return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
+            { completions }
+        </div>;
+    }
+}
diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index 24aa552890..647ffee37a 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
 import sdk from '../../index';
 import dis from '../../dispatcher';
 import { sanitizedHtmlNode } from '../../HtmlUtils';
-import { _t } from '../../languageHandler';
+import { _t, _td, _tJsx } from '../../languageHandler';
 import AccessibleButton from '../views/elements/AccessibleButton';
 import Modal from '../../Modal';
 import classnames from 'classnames';
@@ -32,6 +32,17 @@ import GroupStore from '../../stores/GroupStore';
 import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
 import GeminiScrollbar from 'react-gemini-scrollbar';
 
+const LONG_DESC_PLACEHOLDER = _td(
+`<h1>HTML for your community's page</h1>
+<p>
+    Use the long description to introduce new members to the community, or distribute
+    some important <a href="foo">links</a>
+</p>
+<p>
+    You can even use 'img' tags
+</p>
+`);
+
 const RoomSummaryType = PropTypes.shape({
     room_id: PropTypes.string.isRequired,
     profile: PropTypes.shape({
@@ -392,6 +403,8 @@ export default React.createClass({
 
     propTypes: {
         groupId: PropTypes.string.isRequired,
+        // Whether this is the first time the group admin is viewing the group
+        groupIsNew: PropTypes.bool,
     },
 
     childContextTypes: {
@@ -422,7 +435,7 @@ export default React.createClass({
 
     componentWillMount: function() {
         this._changeAvatarComponent = null;
-        this._initGroupStore(this.props.groupId);
+        this._initGroupStore(this.props.groupId, true);
 
         MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership);
     },
@@ -449,7 +462,7 @@ export default React.createClass({
         this.setState({membershipBusy: false});
     },
 
-    _initGroupStore: function(groupId) {
+    _initGroupStore: function(groupId, firstInit) {
         this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId);
         this._groupStore.registerListener(() => {
             const summary = this._groupStore.getSummary();
@@ -472,6 +485,9 @@ export default React.createClass({
                 ),
                 error: null,
             });
+            if (this.props.groupIsNew && firstInit) {
+                this._onEditClick();
+            }
         });
         this._groupStore.on('error', (err) => {
             this.setState({
@@ -661,6 +677,14 @@ export default React.createClass({
         const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
         const TintableSvg = sdk.getComponent('elements.TintableSvg');
         const Spinner = sdk.getComponent('elements.Spinner');
+        const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
+
+        const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
+            _t(
+                'These rooms are displayed to community members on the community page. '+
+                'Community members can join the rooms by clicking on them.',
+            )
+        } /> : <div />;
 
         const addRoomRow = this.state.editing ?
             (<AccessibleButton className="mx_GroupView_rooms_header_addRow"
@@ -673,14 +697,23 @@ export default React.createClass({
                     { _t('Add rooms to this community') }
                 </div>
             </AccessibleButton>) : <div />;
+        const roomDetailListClassName = classnames({
+            "mx_fadable": true,
+            "mx_fadable_faded": this.state.editing,
+        });
         return <div className="mx_GroupView_rooms">
             <div className="mx_GroupView_rooms_header">
-                <h3>{ _t('Rooms') }</h3>
+                <h3>
+                    { _t('Rooms') }
+                    { roomsHelpNode }
+                </h3>
                 { addRoomRow }
             </div>
             { this.state.groupRoomsLoading ?
                 <Spinner /> :
-                <RoomDetailList rooms={this.state.groupRooms} />
+                <RoomDetailList
+                    rooms={this.state.groupRooms}
+                    className={roomDetailListClassName} />
             }
         </div>;
     },
@@ -851,6 +884,18 @@ export default React.createClass({
         let description = null;
         if (summary.profile && summary.profile.long_description) {
             description = sanitizedHtmlNode(summary.profile.long_description);
+        } else if (this.state.isUserPrivileged) {
+            description = <div
+                className="mx_GroupView_groupDesc_placeholder"
+                onClick={this._onEditClick}
+            >
+                { _tJsx(
+                    'Your community hasn\'t got a Long Description, a HTML page to show to community members.<br />' +
+                    'Click here to open settings and give it one!',
+                    [/<br \/>/],
+                    [(sub) => <br />])
+                }
+            </div>;
         }
         const groupDescEditingClasses = classnames({
             "mx_GroupView_groupDesc": true,
@@ -862,6 +907,7 @@ export default React.createClass({
                 <h3> { _t("Long Description (HTML)") } </h3>
                 <textarea
                     value={this.state.profileForm.long_description}
+                    placeholder={_t(LONG_DESC_PLACEHOLDER)}
                     onChange={this._onLongDescChange}
                     tabIndex="4"
                     key="editLongDesc"
diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js
index 31f59e4849..9a293bfc8a 100644
--- a/src/components/structures/LoggedInView.js
+++ b/src/components/structures/LoggedInView.js
@@ -301,6 +301,7 @@ export default React.createClass({
             case PageTypes.GroupView:
                 page_element = <GroupView
                     groupId={this.props.currentGroupId}
+                    isNew={this.props.currentGroupIsNew}
                     collapsedRhs={this.props.collapseRhs}
                 />;
                 if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index 6d225191a8..14c78bd7dc 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -490,7 +490,10 @@ module.exports = React.createClass({
             case 'view_group':
                 {
                     const groupId = payload.group_id;
-                    this.setState({currentGroupId: groupId});
+                    this.setState({
+                        currentGroupId: groupId,
+                        currentGroupIsNew: payload.group_is_new,
+                    });
                     this._setPage(PageTypes.GroupView);
                     this.notifyNewScreen('group/' + groupId);
                 }
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index fd4c3d0702..38a3392e43 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -301,6 +301,15 @@ module.exports = React.createClass({
     _shouldShowApps: function(room) {
         if (!BROWSER_SUPPORTS_SANDBOX) return false;
 
+        // Check if user has previously chosen to hide the app drawer for this
+        // room. If so, do not show apps
+        let hideWidgetDrawer = localStorage.getItem(
+            room.roomId + "_hide_widget_drawer");
+
+        if (hideWidgetDrawer === "true") {
+            return false;
+        }
+
         const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
         // any valid widget = show apps
         for (let i = 0; i < appsStateEvents.length; i++) {
diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js
index e1dfe388d6..168fe75947 100644
--- a/src/components/views/dialogs/CreateGroupDialog.js
+++ b/src/components/views/dialogs/CreateGroupDialog.js
@@ -81,6 +81,7 @@ export default React.createClass({
             dis.dispatch({
                 action: 'view_group',
                 group_id: result.group_id,
+                group_is_new: true,
             });
             this.props.onFinished(true);
         }).catch((e) => {
diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js
index ee8f307f76..ff2accf0b0 100644
--- a/src/components/views/dialogs/UnknownDeviceDialog.js
+++ b/src/components/views/dialogs/UnknownDeviceDialog.js
@@ -48,8 +48,9 @@ function UserUnknownDeviceList(props) {
     const {userId, userDevices} = props;
 
     const deviceListEntries = Object.keys(userDevices).map((deviceId) =>
-       <DeviceListEntry key={deviceId} userId={userId}
-           device={userDevices[deviceId]} />,
+        <DeviceListEntry key={deviceId} userId={userId}
+            device={userDevices[deviceId]}
+        />,
     );
 
     return (
@@ -92,26 +93,60 @@ export default React.createClass({
     propTypes: {
         room: React.PropTypes.object.isRequired,
 
-        // map from userid -> deviceid -> deviceinfo
-        devices: React.PropTypes.object.isRequired,
         onFinished: React.PropTypes.func.isRequired,
     },
 
-    componentDidMount: function() {
-        // Given we've now shown the user the unknown device, it is no longer
-        // unknown to them. Therefore mark it as 'known'.
-        Object.keys(this.props.devices).forEach((userId) => {
-            Object.keys(this.props.devices[userId]).map((deviceId) => {
-                MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
-            });
+    componentWillMount: function() {
+        this._unmounted = false;
+
+        const roomMembers = this.props.room.getJoinedMembers().map((m) => {
+            return m.userId;
         });
 
-        // XXX: temporary logging to try to diagnose
-        // https://github.com/vector-im/riot-web/issues/3148
-        console.log('Opening UnknownDeviceDialog');
+        this.setState({
+            // map from userid -> deviceid -> deviceinfo
+            devices: null,
+        });
+        MatrixClientPeg.get().downloadKeys(roomMembers, false).then((devices) => {
+            if (this._unmounted) return;
+
+            const unknownDevices = {};
+            // This is all devices in this room, so find the unknown ones.
+            Object.keys(devices).forEach((userId) => {
+                Object.keys(devices[userId]).map((deviceId) => {
+                    const device = devices[userId][deviceId];
+
+                    if (device.isUnverified() && !device.isKnown()) {
+                        if (unknownDevices[userId] === undefined) {
+                            unknownDevices[userId] = {};
+                        }
+                        unknownDevices[userId][deviceId] = device;
+                    }
+
+                    // Given we've now shown the user the unknown device, it is no longer
+                    // unknown to them. Therefore mark it as 'known'.
+                    if (!device.isKnown()) {
+                        MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
+                    }
+                });
+            });
+
+            this.setState({
+                devices: unknownDevices,
+            });
+        });
+    },
+
+    componentWillUnmount: function() {
+        this._unmounted = true;
     },
 
     render: function() {
+        if (this.state.devices === null) {
+            const Spinner = sdk.getComponent("elements.Spinner");
+            return <Spinner />;
+        }
+
         const client = MatrixClientPeg.get();
         const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() ||
               this.props.room.getBlacklistUnverifiedDevices();
@@ -154,7 +189,7 @@ export default React.createClass({
                     { warning }
                     { _t("Unknown devices") }:
 
-                    <UnknownDeviceList devices={this.props.devices} />
+                    <UnknownDeviceList devices={this.state.devices} />
                 </GeminiScrollbar>
                 <div className="mx_Dialog_buttons">
                     <button className="mx_Dialog_primary" autoFocus={true}
diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js
index 3787523a56..b1291710b7 100644
--- a/src/components/views/elements/Dropdown.js
+++ b/src/components/views/elements/Dropdown.js
@@ -26,11 +26,9 @@ class MenuOption extends React.Component {
         this._onClick = this._onClick.bind(this);
     }
 
-    getDefaultProps() {
-        return {
-            disabled: false,
-        };
-    }
+    static defaultProps = {
+        disabled: false,
+    };
 
     _onMouseEnter() {
         this.props.onMouseEnter(this.props.dropdownKey);
diff --git a/src/components/views/elements/ToolTipButton.js b/src/components/views/elements/ToolTipButton.js
new file mode 100644
index 0000000000..b5b2d735ee
--- /dev/null
+++ b/src/components/views/elements/ToolTipButton.js
@@ -0,0 +1,55 @@
+/*
+Copyright 2017 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.
+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.
+*/
+
+import React from 'react';
+import sdk from '../../../index';
+
+module.exports = React.createClass({
+    displayName: 'ToolTipButton',
+
+    getInitialState: function() {
+        return {
+            hover: false,
+        };
+    },
+
+    onMouseOver: function() {
+        this.setState({
+            hover: true,
+        });
+    },
+
+    onMouseOut: function() {
+        this.setState({
+            hover: false,
+        });
+    },
+
+    render: function() {
+        const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
+        const tip = this.state.hover ? <RoomTooltip
+            className="mx_ToolTipButton_container"
+            tooltipClassName="mx_ToolTipButton_helpText"
+            label={this.props.helpText}
+        /> : <div />;
+        return (
+            <div className="mx_ToolTipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
+                ?
+                { tip }
+            </div>
+        );
+    },
+});
diff --git a/src/components/views/groups/GroupMemberTile.js b/src/components/views/groups/GroupMemberTile.js
index f40c7ed1c5..84c2adcb41 100644
--- a/src/components/views/groups/GroupMemberTile.js
+++ b/src/components/views/groups/GroupMemberTile.js
@@ -61,9 +61,9 @@ export default withMatrixClient(React.createClass({
         );
 
         return (
-            <EntityTile presenceState="online"
-                avatarJsx={av} onClick={this.onClick}
-                name={name} powerLevel={0} suppressOnHover={true}
+            <EntityTile name={name} avatarJsx={av} onClick={this.onClick}
+                suppressOnHover={true} presenceState="online"
+                powerStatus={this.props.member.isAdmin ? EntityTile.POWER_STATUS_ADMIN : null}
             />
         );
     },
diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js
index 1c9296228d..9a3ba5f329 100644
--- a/src/components/views/rooms/AppsDrawer.js
+++ b/src/components/views/rooms/AppsDrawer.js
@@ -81,16 +81,25 @@ module.exports = React.createClass({
     },
 
     onAction: function(action) {
+        const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer";
         switch (action.action) {
             case 'appsDrawer':
-                // When opening the app draw when there aren't any apps, auto-launch the
-                // integrations manager to skip the awkward click on "Add widget"
+                // When opening the app drawer when there aren't any apps,
+                // auto-launch the integrations manager to skip the awkward
+                // click on "Add widget"
                 if (action.show) {
                     const apps = this._getApps();
                     if (apps.length === 0) {
                         this._launchManageIntegrations();
                     }
+
+                    localStorage.removeItem(hideWidgetKey);
+                } else {
+                    // Store hidden state of widget
+                    // Don't show if previously hidden
+                    localStorage.setItem(hideWidgetKey, true);
                 }
+
                 break;
         }
     },
diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js
index 1df19340cd..ffcb289437 100644
--- a/src/components/views/rooms/EntityTile.js
+++ b/src/components/views/rooms/EntityTile.js
@@ -47,7 +47,7 @@ function presenceClassForMember(presenceState, lastActiveAgo) {
     }
 }
 
-module.exports = React.createClass({
+const EntityTile = React.createClass({
     displayName: 'EntityTile',
 
     propTypes: {
@@ -140,16 +140,19 @@ module.exports = React.createClass({
         }
 
         let power;
-        const powerLevel = this.props.powerLevel;
-        if (powerLevel >= 50 && powerLevel < 99) {
-            power = <img src="img/mod.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Moderator")} />;
-        }
-        if (powerLevel >= 99) {
-            power = <img src="img/admin.svg" className="mx_EntityTile_power" width="16" height="17" alt={_t("Admin")} />;
+        const powerStatus = this.props.powerStatus;
+        if (powerStatus) {
+            const src = {
+                [EntityTile.POWER_STATUS_MODERATOR]: "img/mod.svg",
+                [EntityTile.POWER_STATUS_ADMIN]: "img/admin.svg",
+            }[powerStatus];
+            const alt = {
+                [EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"),
+                [EntityTile.POWER_STATUS_ADMIN]: _t("Admin"),
+            }[powerStatus];
+            power = <img src={src} className="mx_EntityTile_power" width="16" height="17" alt={alt} />;
         }
 
-
-        const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
         const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
 
         const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
@@ -168,3 +171,9 @@ module.exports = React.createClass({
         );
     },
 });
+
+EntityTile.POWER_STATUS_MODERATOR = "moderator";
+EntityTile.POWER_STATUS_ADMIN = "admin";
+
+
+export default EntityTile;
diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js
index e21f7c91f4..4848c4b258 100644
--- a/src/components/views/rooms/MemberTile.js
+++ b/src/components/views/rooms/MemberTile.js
@@ -86,13 +86,19 @@ module.exports = React.createClass({
         }
         this.member_last_modified_time = member.getLastModifiedTime();
 
+        // We deliberately leave power levels that are not 100 or 50 undefined
+        const powerStatus = {
+            100: EntityTile.POWER_STATUS_ADMIN,
+            50: EntityTile.POWER_STATUS_MODERATOR,
+        }[this.props.member.powerLevel];
+
         return (
             <EntityTile {...this.props} presenceState={presenceState}
                 presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
                 presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
                 presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
                 avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
-                name={name} powerLevel={this.props.member.powerLevel} />
+                name={name} powerStatus={powerStatus} />
         );
     },
 });
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js
index eef7f410cc..87c56db68e 100644
--- a/src/components/views/rooms/MessageComposerInput.js
+++ b/src/components/views/rooms/MessageComposerInput.js
@@ -58,6 +58,11 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
 
 const ZWS_CODE = 8203;
 const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
+
+const ENTITY_TYPES = {
+    AT_ROOM_PILL: 'ATROOMPILL',
+};
+
 function stateToMarkdown(state) {
     return __stateToMarkdown(state)
         .replace(
@@ -188,13 +193,16 @@ export default class MessageComposerInput extends React.Component {
         this.client = MatrixClientPeg.get();
     }
 
-    findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
+    findPillEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
         contentBlock.findEntityRanges(
             (character) => {
                 const entityKey = character.getEntity();
                 return (
                     entityKey !== null &&
-                    contentState.getEntity(entityKey).getType() === 'LINK'
+                    (
+                        contentState.getEntity(entityKey).getType() === 'LINK' ||
+                        contentState.getEntity(entityKey).getType() === ENTITY_TYPES.AT_ROOM_PILL
+                    )
                 );
             }, callback,
         );
@@ -210,11 +218,19 @@ export default class MessageComposerInput extends React.Component {
                 RichText.getScopedMDDecorators(this.props);
         const shouldShowPillAvatar = !SettingsStore.getValue("Pill.shouldHidePillAvatar");
         decorators.push({
-            strategy: this.findLinkEntities.bind(this),
+            strategy: this.findPillEntities.bind(this),
             component: (entityProps) => {
                 const Pill = sdk.getComponent('elements.Pill');
+                const type = entityProps.contentState.getEntity(entityProps.entityKey).getType();
                 const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData();
-                if (Pill.isPillUrl(url)) {
+                if (type === ENTITY_TYPES.AT_ROOM_PILL) {
+                    return <Pill
+                        type={Pill.TYPE_AT_ROOM_MENTION}
+                        room={this.props.room}
+                        offsetKey={entityProps.offsetKey}
+                        shouldShowPillAvatar={shouldShowPillAvatar}
+                    />;
+                } else if (Pill.isPillUrl(url)) {
                     return <Pill
                         url={url}
                         room={this.props.room}
@@ -784,7 +800,7 @@ export default class MessageComposerInput extends React.Component {
             const pt = contentState.getBlocksAsArray().map((block) => {
                 let blockText = block.getText();
                 let offset = 0;
-                this.findLinkEntities(contentState, block, (start, end) => {
+                this.findPillEntities(contentState, block, (start, end) => {
                     const entity = contentState.getEntity(block.getEntityAt(start));
                     if (entity.getType() !== 'LINK') {
                         return;
@@ -989,6 +1005,11 @@ export default class MessageComposerInput extends React.Component {
                 isCompletion: true,
             });
             entityKey = contentState.getLastCreatedEntityKey();
+        } else if (completion === '@room') {
+            contentState = contentState.createEntity(ENTITY_TYPES.AT_ROOM_PILL, 'IMMUTABLE', {
+                isCompletion: true,
+            });
+            entityKey = contentState.getLastCreatedEntityKey();
         }
 
         let selection;
diff --git a/src/components/views/rooms/PinnedEventsPanel.js b/src/components/views/rooms/PinnedEventsPanel.js
index 5b1b8a4590..a8fb35fcd5 100644
--- a/src/components/views/rooms/PinnedEventsPanel.js
+++ b/src/components/views/rooms/PinnedEventsPanel.js
@@ -19,6 +19,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
 import AccessibleButton from "../elements/AccessibleButton";
 import PinnedEventTile from "./PinnedEventTile";
 import { _t } from '../../../languageHandler';
+import PinningUtils from "../../../utils/PinningUtils";
 
 module.exports = React.createClass({
     displayName: 'PinnedEventsPanel',
@@ -61,20 +62,39 @@ module.exports = React.createClass({
 
             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;
-                });
+                const pinned = contexts.filter((context) => PinningUtils.isPinnable(context.event));
 
                 this.setState({ loading: false, pinned });
             });
         }
+
+        this._updateReadState();
+    },
+
+    _updateReadState: function() {
+        const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
+        if (!pinnedEvents) return; // nothing to read
+
+        let readStateEvents = [];
+        const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
+        if (readPinsEvent && readPinsEvent.getContent()) {
+            readStateEvents = readPinsEvent.getContent().event_ids || [];
+        }
+
+        if (!readStateEvents.includes(pinnedEvents.getId())) {
+            readStateEvents.push(pinnedEvents.getId());
+
+            // Only keep the last 10 event IDs to avoid infinite growth
+            readStateEvents = readStateEvents.reverse().splice(0, 10).reverse();
+
+            MatrixClientPeg.get().setRoomAccountData(this.props.room.roomId, "im.vector.room.read_pins", {
+                event_ids: readStateEvents,
+            });
+        }
     },
 
     _getPinnedTiles: function() {
-        if (this.state.pinned.length == 0) {
+        if (this.state.pinned.length === 0) {
             return (<div>{ _t("No pinned messages.") }</div>);
         }
 
diff --git a/src/components/views/rooms/RoomDetailList.js b/src/components/views/rooms/RoomDetailList.js
index c44b662fa7..27972af484 100644
--- a/src/components/views/rooms/RoomDetailList.js
+++ b/src/components/views/rooms/RoomDetailList.js
@@ -23,6 +23,7 @@ import sanitizeHtml from 'sanitize-html';
 import { ContentRepo } from 'matrix-js-sdk';
 import MatrixClientPeg from '../../../MatrixClientPeg';
 import PropTypes from 'prop-types';
+import classNames from 'classnames';
 
 function getDisplayAliasForRoom(room) {
     return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@@ -117,6 +118,8 @@ export default React.createClass({
             worldReadable: PropTypes.bool,
             guestCanJoin: PropTypes.bool,
         })),
+
+        className: PropTypes.string,
     },
 
     getRows: function() {
@@ -138,7 +141,7 @@ export default React.createClass({
                 </tbody>
             </table>;
         }
-        return <div className="mx_RoomDetailList">
+        return <div className={classNames("mx_RoomDetailList", this.props.className)}>
             { rooms }
         </div>;
     },
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js
index fbfe7ebe18..a44673c879 100644
--- a/src/components/views/rooms/RoomHeader.js
+++ b/src/components/views/rooms/RoomHeader.js
@@ -65,6 +65,7 @@ module.exports = React.createClass({
     componentDidMount: function() {
         const cli = MatrixClientPeg.get();
         cli.on("RoomState.events", this._onRoomStateEvents);
+        cli.on("Room.accountData", this._onRoomAccountData);
 
         // When a room name occurs, RoomState.events is fired *before*
         // room.name is updated. So we have to listen to Room.name as well as
@@ -87,6 +88,7 @@ module.exports = React.createClass({
         const cli = MatrixClientPeg.get();
         if (cli) {
             cli.removeListener("RoomState.events", this._onRoomStateEvents);
+            cli.removeListener("Room.accountData", this._onRoomAccountData);
         }
     },
 
@@ -99,6 +101,13 @@ module.exports = React.createClass({
         this._rateLimitedUpdate();
     },
 
+    _onRoomAccountData: function(event, room) {
+        if (!this.props.room || room.roomId !== this.props.room.roomId) return;
+        if (event.getType() !== "im.vector.room.read_pins") return;
+
+        this._rateLimitedUpdate();
+    },
+
     _rateLimitedUpdate: new RateLimitedFunc(function() {
         /* eslint-disable babel/no-invalid-this */
         this.forceUpdate();
@@ -139,6 +148,32 @@ module.exports = React.createClass({
         dis.dispatch({ action: 'show_right_panel' });
     },
 
+    _hasUnreadPins: function() {
+        const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
+        if (!currentPinEvent) return false;
+        if (currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0) {
+            return false; // no pins == nothing to read
+        }
+
+        const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
+        if (readPinsEvent && readPinsEvent.getContent()) {
+            const readStateEvents = readPinsEvent.getContent().event_ids || [];
+            if (readStateEvents) {
+                return !readStateEvents.includes(currentPinEvent.getId());
+            }
+        }
+
+        // There's pins, and we haven't read any of them
+        return true;
+    },
+
+    _hasPins: function() {
+        const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
+        if (!currentPinEvent) return false;
+
+        return !(currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0);
+    },
+
     /**
      * After editing the settings, get the new name for the room
      *
@@ -305,8 +340,17 @@ module.exports = React.createClass({
         }
 
         if (this.props.onPinnedClick && SettingsStore.isFeatureEnabled('feature_pinning')) {
+            let pinsIndicator = null;
+            if (this._hasUnreadPins()) {
+                pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator mx_RoomHeader_pinsIndicatorUnread" />);
+            } else if (this._hasPins()) {
+                pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator" />);
+            }
+
             pinnedEventsButton =
-                <AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
+                <AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
+                                  onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
+                    { pinsIndicator }
                     <TintableSvg src="img/icons-pin.svg" width="16" height="16" />
                 </AccessibleButton>;
         }
diff --git a/src/groups.js b/src/groups.js
index 6c266e0fb6..957db1d85b 100644
--- a/src/groups.js
+++ b/src/groups.js
@@ -36,6 +36,7 @@ export function groupMemberFromApiObject(apiObject) {
         userId: apiObject.user_id,
         displayname: apiObject.displayname,
         avatarUrl: apiObject.avatar_url,
+        isAdmin: apiObject.is_admin,
     };
 }
 
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 42bf74fb0e..b94e89a113 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -669,6 +669,7 @@
     "You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality",
     "You must join the room to see its files": "You must join the room to see its files",
     "There are no visible files in this room": "There are no visible files in this room",
+    "<h1>HTML for your community's page</h1>\n<p>\n    Use the long description to introduce new members to the community, or distribute\n    some important <a href=\"foo\">links</a>\n</p>\n<p>\n    You can even use 'img' tags\n</p>\n": "<h1>HTML for your community's page</h1>\n<p>\n    Use the long description to introduce new members to the community, or distribute\n    some important <a href=\"foo\">links</a>\n</p>\n<p>\n    You can even use 'img' tags\n</p>\n",
     "Add rooms to the community summary": "Add rooms to the community summary",
     "Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?",
     "Add to summary": "Add to summary",
@@ -691,6 +692,7 @@
     "Leave": "Leave",
     "Unable to leave room": "Unable to leave room",
     "Community Settings": "Community Settings",
+    "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.",
     "Add rooms to this community": "Add rooms to this community",
     "Featured Rooms:": "Featured Rooms:",
     "Featured Users:": "Featured Users:",
@@ -699,6 +701,7 @@
     "You are a member of this community": "You are a member of this community",
     "Community Member Settings": "Community Member Settings",
     "Publish this community on your profile": "Publish this community on your profile",
+    "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
     "Long Description (HTML)": "Long Description (HTML)",
     "Description": "Description",
     "Community %(groupId)s not found": "Community %(groupId)s not found",
@@ -889,6 +892,8 @@
     "Commands": "Commands",
     "Results from DuckDuckGo": "Results from DuckDuckGo",
     "Emoji": "Emoji",
+    "Notify the whole room": "Notify the whole room",
+    "Room Notification": "Room Notification",
     "Users": "Users",
     "unknown device": "unknown device",
     "NOT verified": "NOT verified",
diff --git a/src/stores/GroupStore.js b/src/stores/GroupStore.js
index 2578d373a7..11dd664053 100644
--- a/src/stores/GroupStore.js
+++ b/src/stores/GroupStore.js
@@ -33,6 +33,9 @@ export default class GroupStore extends EventEmitter {
 
     constructor(matrixClient, groupId) {
         super();
+        if (!groupId) {
+            throw new Error('GroupStore needs a valid groupId to be created');
+        }
         this.groupId = groupId;
         this._matrixClient = matrixClient;
         this._summary = {};
diff --git a/src/utils/PinningUtils.js b/src/utils/PinningUtils.js
new file mode 100644
index 0000000000..90d26cc988
--- /dev/null
+++ b/src/utils/PinningUtils.js
@@ -0,0 +1,30 @@
+/*
+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.
+*/
+
+export default class PinningUtils {
+    /**
+     * Determines if the given event may be pinned.
+     * @param {MatrixEvent} event The event to check.
+     * @return {boolean} True if the event may be pinned, false otherwise.
+     */
+    static isPinnable(event) {
+        if (!event) return false;
+        if (event.getType() !== "m.room.message") return false;
+        if (event.isRedacted()) return false;
+
+        return true;
+    }
+}