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(""); +}