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; + } +}