Merge branch 'develop' into luke/rts-welcome-pages

Conflicts:
	src/components/views/avatars/BaseAvatar.js
This commit is contained in:
Luke Barnard 2017-02-01 17:22:45 +00:00
commit acde1f3db7
153 changed files with 5544 additions and 1599 deletions

View file

@ -47,7 +47,7 @@ module.exports = {
return container;
},
createMenu: function (Element, props) {
createMenu: function(Element, props) {
var self = this;
var closeMenu = function() {
@ -67,7 +67,7 @@ module.exports = {
chevronOffset.top = props.chevronOffset;
}
// To overide the deafult chevron colour, if it's been set
// To override the default chevron colour, if it's been set
var chevronCSS = "";
if (props.menuColour) {
chevronCSS = `
@ -78,15 +78,15 @@ module.exports = {
.mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour};
}
`
`;
}
var chevron = null;
if (props.left) {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>;
position.left = props.left;
} else {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>;
position.right = props.right;
}

View file

@ -118,7 +118,7 @@ module.exports = React.createClass({
var self = this;
deferred.then(function (resp) {
deferred.then(function(resp) {
self.setState({
phase: self.phases.CREATED,
});
@ -210,7 +210,7 @@ module.exports = React.createClass({
onAliasChanged: function(alias) {
this.setState({
alias: alias
})
});
},
onEncryptChanged: function(ev) {

View file

@ -35,7 +35,7 @@ var FilePanel = React.createClass({
getInitialState: function() {
return {
timelineSet: null,
}
};
},
componentWillMount: function() {

View file

@ -21,6 +21,7 @@ import KeyCode from '../../KeyCode';
import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes';
import sdk from '../../index';
import dis from '../../dispatcher';
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
@ -161,8 +162,8 @@ export default React.createClass({
collapsedRhs={this.props.collapse_rhs}
ConferenceHandler={this.props.ConferenceHandler}
scrollStateMap={this._scrollStateMap}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.sideOpacity} />
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.sideOpacity} />;
break;
case PageTypes.UserSettings:
@ -171,24 +172,25 @@ export default React.createClass({
brand={this.props.config.brand}
collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
referralBaseUrl={this.props.config.referralBaseUrl}
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;
case PageTypes.CreateRoom:
page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapse_rhs}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
collapsedRhs={this.props.collapse_rhs}
config={this.props.config.roomDirectory}
/>
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;
case PageTypes.HomePage:
@ -201,7 +203,7 @@ export default React.createClass({
case PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} />
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} />;
break;
}

View file

@ -66,15 +66,28 @@ module.exports = React.createClass({
defaultDeviceDisplayName: React.PropTypes.string,
},
childContextTypes: {
appConfig: React.PropTypes.object,
},
AuxPanel: {
RoomSettings: "room_settings",
},
getChildContext: function() {
return {
appConfig: this.props.config,
};
},
getInitialState: function() {
var s = {
loading: true,
screen: undefined,
// What the LoggedInView would be showing if visible
page_type: null,
// If we are viewing a room by alias, this contains the alias
currentRoomAlias: null,
@ -235,8 +248,6 @@ module.exports = React.createClass({
setStateForNewScreen: function(state) {
const newState = {
screen: undefined,
currentRoomAlias: null,
currentRoomId: null,
viewUserId: null,
logged_in: false,
ready: false,
@ -449,6 +460,9 @@ module.exports = React.createClass({
middleOpacity: payload.middleOpacity,
});
break;
case 'set_theme':
this._onSetTheme(payload.value);
break;
case 'on_logged_in':
this._onLoggedIn();
break;
@ -579,6 +593,50 @@ module.exports = React.createClass({
this.setState({loading: false});
},
/**
* Called whenever someone changes the theme
*/
_onSetTheme: function(theme) {
if (!theme) {
theme = 'light';
}
// look for the stylesheet elements.
// styleElements is a map from style name to HTMLLinkElement.
var styleElements = Object.create(null);
var i, a;
for (i = 0; (a = document.getElementsByTagName("link")[i]); i++) {
var href = a.getAttribute("href");
// shouldn't we be using the 'title' tag rather than the href?
var match = href.match(/^bundles\/.*\/theme-(.*)\.css$/);
if (match) {
styleElements[match[1]] = a;
}
}
if (!(theme in styleElements)) {
throw new Error("Unknown theme " + theme);
}
// disable all of them first, then enable the one we want. Chrome only
// bothers to do an update on a true->false transition, so this ensures
// that we get exactly one update, at the right time.
Object.values(styleElements).forEach((a) => {
a.disabled = true;
});
styleElements[theme].disabled = false;
if (theme === 'dark') {
// abuse the tinter to change all the SVG's #fff to #2d2d2d
// XXX: obviously this shouldn't be hardcoded here.
Tinter.tintSvgWhite('#2d2d2d');
}
else {
Tinter.tintSvgWhite('#ffffff');
}
},
/**
* Called when a new logged in session has started
*/
@ -601,6 +659,9 @@ module.exports = React.createClass({
ready: false,
collapse_lhs: false,
collapse_rhs: false,
currentRoomAlias: null,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
},
@ -687,6 +748,16 @@ module.exports = React.createClass({
action: 'logout'
});
});
cli.on("accountData", function(ev) {
if (ev.getType() === 'im.vector.web.settings') {
if (ev.getContent() && ev.getContent().theme) {
dis.dispatch({
action: 'set_theme',
value: ev.getContent().theme,
});
}
}
});
},
onFocus: function(ev) {
@ -983,7 +1054,7 @@ module.exports = React.createClass({
{...this.props}
{...this.state}
/>
)
);
} else if (this.state.logged_in) {
// we think we are logged in, but are still waiting for the /sync to complete
var Spinner = sdk.getComponent('elements.Spinner');
@ -1002,11 +1073,13 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
username={this.state.upgradeUsername}
guestAccessToken={this.state.guestAccessToken}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand}
teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl}
@ -1025,6 +1098,7 @@ module.exports = React.createClass({
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onLoginClick={this.onLoginClick} />
);
} else {

View file

@ -19,7 +19,9 @@ var ReactDOM = require("react-dom");
var dis = require("../../dispatcher");
var sdk = require('../../index');
var MatrixClientPeg = require('../../MatrixClientPeg')
var MatrixClientPeg = require('../../MatrixClientPeg');
const MILLIS_IN_DAY = 86400000;
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
@ -229,6 +231,7 @@ module.exports = React.createClass({
_getEventTiles: function() {
var EventTile = sdk.getComponent('rooms.EventTile');
var DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
this.eventNodes = {};
@ -278,8 +281,7 @@ module.exports = React.createClass({
var isMembershipChange = (e) =>
e.getType() === 'm.room.member'
&& ['join', 'leave'].indexOf(e.event.content.membership) !== -1
&& (!e.event.prev_content || e.event.content.membership !== e.event.prev_content.membership);
&& (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership);
for (i = 0; i < this.props.events.length; i++) {
var mxEv = this.props.events[i];
@ -292,37 +294,63 @@ module.exports = React.createClass({
var last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary
if (isMembershipChange(mxEv)) {
let summarisedEvents = [mxEv];
i++;
for (;i < this.props.events.length; i++) {
let collapsedMxEv = this.props.events[i];
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) {
let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
// instead will allow new props to be provided. In turn, the shouldComponentUpdate
// method on MELS can be used to prevent unnecessary renderings.
//
// Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null,
// so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first
// membership event, which will not change during forward pagination.
const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial");
if (!isMembershipChange(collapsedMxEv)) {
i--;
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
let dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1}/></li>;
ret.push(dateSeparator);
}
let summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) {
let collapsedMxEv = this.props.events[i + 1];
// Ignore redacted member events
if (!EventTile.haveTileForEvent(collapsedMxEv)) {
continue;
}
if (!isMembershipChange(collapsedMxEv) ||
this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) {
break;
}
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = this.props.events.length OR i = the index of the last
// MembershipChange in a sequence of MembershipChanges
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map(
(e) => {
let ret = this._getTilesForEvent(prevEvent, e);
// In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted.
let ret = this._getTilesForEvent(e, e);
prevEvent = e;
return ret;
}
).reduce((a,b) => a.concat(b));
).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) {
eventTiles = null;
}
ret.push(
<MemberEventListSummary key={mxEv.getId()} events={summarisedEvents}>
{eventTiles}
<MemberEventListSummary
key={key}
events={summarisedEvents}
data-scroll-token={eventId}>
{eventTiles}
</MemberEventListSummary>
);
continue;
@ -405,12 +433,14 @@ module.exports = React.createClass({
// local echoes have a fake date, which could even be yesterday. Treat them
// as 'today' for the date separators.
var ts1 = mxEv.getTs();
var eventDate = mxEv.getDate();
if (mxEv.status) {
ts1 = new Date();
eventDate = new Date();
ts1 = eventDate.getTime();
}
// do we need a date separator since the last event?
if (this._wantsDateSeparator(prevEvent, ts1)) {
if (this._wantsDateSeparator(prevEvent, eventDate)) {
var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
ret.push(dateSeparator);
continuation = false;
@ -447,38 +477,48 @@ module.exports = React.createClass({
return ret;
},
_wantsDateSeparator: function(prevEvent, nextEventTs) {
_wantsDateSeparator: function(prevEvent, nextEventDate) {
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.
return !this.props.suppressFirstDateSeparator;
}
return (new Date(prevEvent.getTs()).toDateString()
!== new Date(nextEventTs).toDateString());
},
// get a list of the userids whose read receipts should
// be shown next to this event
_getReadReceiptsForEvent: function(event) {
var myUserId = MatrixClientPeg.get().credentials.userId;
// get list of read receipts, sorted most recent first
var room = MatrixClientPeg.get().getRoom(event.getRoomId());
if (!room) {
// huh.
return null;
// Return early for events that are > 24h apart
if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
return room.getReceiptsForEvent(event).filter(function(r) {
return r.type === "m.read" && r.userId != myUserId;
}).sort(function(r1, r2) {
return r2.data.ts - r1.data.ts;
}).map(function(r) {
return room.getMember(r.userId);
}).filter(function(m) {
// check that the user is a known room member
return m;
// Compare weekdays
return prevEvent.getDate().getDay() !== nextEventDate.getDay();
},
// get a list of read receipts that should be shown next to this event
// Receipts are objects which have a 'roomMember' and 'ts'.
_getReadReceiptsForEvent: function(event) {
const myUserId = MatrixClientPeg.get().credentials.userId;
// get list of read receipts, sorted most recent first
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
if (!room) {
return null;
}
let receipts = [];
room.getReceiptsForEvent(event).forEach((r) => {
if (!r.userId || r.type !== "m.read" || r.userId === myUserId) {
return; // ignore non-read receipts and receipts from self.
}
let member = room.getMember(r.userId);
if (!member) {
return; // ignore unknown user IDs
}
receipts.push({
roomMember: member,
ts: r.data ? r.data.ts : 0,
});
});
return receipts.sort((r1, r2) => {
return r2.ts - r1.ts;
});
},
@ -564,6 +604,7 @@ module.exports = React.createClass({
onScroll={ this.props.onScroll }
onResize={ this.onResize }
onFillRequest={ this.props.onFillRequest }
onUnfillRequest={ this.props.onUnfillRequest }
style={ style }
stickyBottom={ this.props.stickyBottom }>
{topSpinner}

View file

@ -19,6 +19,12 @@ var sdk = require('../../index');
var dis = require("../../dispatcher");
var WhoIsTyping = require("../../WhoIsTyping");
var MatrixClientPeg = require("../../MatrixClientPeg");
const MemberAvatar = require("../views/avatars/MemberAvatar");
const HIDE_DEBOUNCE_MS = 10000;
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
module.exports = React.createClass({
displayName: 'RoomStatusBar',
@ -45,6 +51,10 @@ module.exports = React.createClass({
// more interesting)
hasActiveCall: React.PropTypes.bool,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: React.PropTypes.number,
// callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar
onResendAllClick: React.PropTypes.func,
@ -60,12 +70,28 @@ module.exports = React.createClass({
// status bar. This is used to trigger a re-layout in the parent
// component.
onResize: React.PropTypes.func,
// callback for when the status bar can be hidden from view, as it is
// not displaying anything
onHidden: React.PropTypes.func,
// callback for when the status bar is displaying something and should
// be visible
onVisible: React.PropTypes.func,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 2,
};
},
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
whoisTypingString: WhoIsTyping.whoIsTypingString(
this.props.room,
this.props.whoIsTypingLimit
),
};
},
@ -78,6 +104,18 @@ module.exports = React.createClass({
if(this.props.onResize && this._checkForResize(prevProps, prevState)) {
this.props.onResize();
}
const size = this._getSize(this.state, this.props);
if (size > 0) {
this.props.onVisible();
} else {
if (this.hideDebouncer) {
clearTimeout(this.hideDebouncer);
}
this.hideDebouncer = setTimeout(() => {
this.props.onHidden();
}, HIDE_DEBOUNCE_MS);
}
},
componentWillUnmount: function() {
@ -100,39 +138,35 @@ module.exports = React.createClass({
onRoomMemberTyping: function(ev, member) {
this.setState({
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
whoisTypingString: WhoIsTyping.whoIsTypingString(
this.props.room,
this.props.whoIsTypingLimit
),
});
},
// We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function(state, props) {
if (state.syncState === "ERROR" ||
state.whoisTypingString ||
props.numUnreadMessages ||
!props.atEndOfLiveTimeline ||
props.hasActiveCall) {
return STATUS_BAR_EXPANDED;
} else if (props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN;
} else if (props.hasUnsentMessages) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
},
// determine if we need to call onResize
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar. We
// don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
var oldSize, newSize;
if (prevState.syncState === "ERROR") {
oldSize = 1;
} else if (prevProps.tabCompleteEntries) {
oldSize = 0;
} else if (prevProps.hasUnsentMessages) {
oldSize = 2;
} else {
oldSize = 0;
}
if (this.state.syncState === "ERROR") {
newSize = 1;
} else if (this.props.tabCompleteEntries) {
newSize = 0;
} else if (this.props.hasUnsentMessages) {
newSize = 2;
} else {
newSize = 0;
}
return newSize != oldSize;
// figure out the old height and the new height of the status bar.
return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state);
},
// return suitable content for the image on the left of the status bar.
@ -173,10 +207,8 @@ module.exports = React.createClass({
if (wantPlaceholder) {
return (
<div className="mx_RoomStatusBar_placeholderIndicator">
<span>.</span>
<span>.</span>
<span>.</span>
<div className="mx_RoomStatusBar_typingIndicatorAvatars">
{this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit)}
</div>
);
}
@ -184,6 +216,36 @@ module.exports = React.createClass({
return null;
},
_renderTypingIndicatorAvatars: function(limit) {
let users = WhoIsTyping.usersTypingApartFromMe(this.props.room);
let othersCount = Math.max(users.length - limit, 0);
users = users.slice(0, limit);
let avatars = users.map((u, index) => {
let showInitial = othersCount === 0 && index === users.length - 1;
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
defaultToInitialLetter={showInitial}
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining">
+{othersCount}
</span>
);
}
return avatars;
},
// return suitable content for the main (text) part of the status bar.
_getContent: function() {

View file

@ -48,7 +48,7 @@ if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
var debuglog = function() {};
}
module.exports = React.createClass({
@ -146,7 +146,9 @@ module.exports = React.createClass({
showTopUnreadMessagesBar: false,
auxPanelMaxHeight: undefined,
}
statusBarVisible: false,
};
},
componentWillMount: function() {
@ -674,8 +676,9 @@ module.exports = React.createClass({
},
onSearchResultsFillRequest: function(backwards) {
if (!backwards)
if (!backwards) {
return q(false);
}
if (this.state.searchResults.next_batch) {
debuglog("requesting more search results");
@ -719,15 +722,11 @@ module.exports = React.createClass({
if (!result.displayname) {
var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog');
var dialog_defer = q.defer();
var dialog_ref;
Modal.createDialog(SetDisplayNameDialog, {
currentDisplayName: result.displayname,
ref: (r) => {
dialog_ref = r;
},
onFinished: (submitted) => {
onFinished: (submitted, newDisplayName) => {
if (submitted) {
cli.setDisplayName(dialog_ref.getValue()).done(() => {
cli.setDisplayName(newDisplayName).done(() => {
dialog_defer.resolve();
});
}
@ -758,7 +757,7 @@ module.exports = React.createClass({
}).then(() => {
var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined;
return MatrixClientPeg.get().joinRoom(this.props.roomAddress,
{ inviteSignUrl: sign_url } )
{ inviteSignUrl: sign_url } );
}).then(function(resp) {
var roomId = resp.roomId;
@ -810,11 +809,6 @@ module.exports = React.createClass({
} else {
var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
if (msg === "No known servers") {
// minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed
// 'Error when trying to join an empty room should be more explicit'
msg = "It is not currently possible to re-join an empty room.";
}
Modal.createDialog(ErrorDialog, {
title: "Failed to join room",
description: msg
@ -967,7 +961,7 @@ module.exports = React.createClass({
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) {
return b.length - a.length });
return b.length - a.length; });
self.setState({
searchHighlights: highlights,
@ -1030,7 +1024,7 @@ module.exports = React.createClass({
if (scrollPanel) {
scrollPanel.checkScroll();
}
}
};
var lastRoomId;
@ -1095,7 +1089,7 @@ module.exports = React.createClass({
}
this.refs.room_settings.save().then((results) => {
var fails = results.filter(function(result) { return result.state !== "fulfilled" });
var fails = results.filter(function(result) { return result.state !== "fulfilled"; });
console.log("Settings saved with %s errors", fails.length);
if (fails.length) {
fails.forEach(function(result) {
@ -1104,7 +1098,7 @@ module.exports = React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failed to save settings",
description: fails.map(function(result) { return result.reason }).join("\n"),
description: fails.map(function(result) { return result.reason; }).join("\n"),
});
// still editing room settings
}
@ -1188,7 +1182,7 @@ module.exports = React.createClass({
this.setState({ searching: true });
},
onCancelSearchClick: function () {
onCancelSearchClick: function() {
this.setState({
searching: false,
searchResults: null,
@ -1213,8 +1207,9 @@ module.exports = React.createClass({
// decide whether or not the top 'unread messages' bar should be shown
_updateTopUnreadMessagesBar: function() {
if (!this.refs.messagePanel)
if (!this.refs.messagePanel) {
return;
}
var pos = this.refs.messagePanel.getReadMarkerPosition();
@ -1336,6 +1331,20 @@ module.exports = React.createClass({
// no longer anything to do here
},
onStatusBarVisible: function() {
if (this.unmounted) return;
this.setState({
statusBarVisible: true,
});
},
onStatusBarHidden: function() {
if (this.unmounted) return;
this.setState({
statusBarVisible: false,
});
},
showSettings: function(show) {
// XXX: this is a bit naughty; we should be doing this via props
if (show) {
@ -1500,13 +1509,14 @@ module.exports = React.createClass({
});
var statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) {
var UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} />
statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar
room={this.state.room}
tabComplete={this.tabComplete}
@ -1518,7 +1528,10 @@ module.exports = React.createClass({
onCancelAllClick={this.onCancelAllClick}
onScrollToBottomClick={this.jumpToLiveTimeline}
onResize={this.onChildResize}
/>
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={2}
/>;
}
var aux = null;
@ -1574,7 +1587,7 @@ module.exports = React.createClass({
messageComposer =
<MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>;
}
// TODO: Why aren't we storing the term/scope/count in this format
@ -1602,14 +1615,14 @@ module.exports = React.createClass({
<img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"}
alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"}
width="31" height="27"/>
</div>
</div>;
}
voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"}
alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"}
width="21" height="26"/>
</div>
</div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar =
@ -1619,7 +1632,7 @@ module.exports = React.createClass({
{ zoomButton }
{ statusBar }
<TintableSvg className="mx_RoomView_voipChevron" src="img/voip-chevron.svg" width="22" height="17"/>
</div>
</div>;
}
// if we have search results, we keep the messagepanel (so that it preserves its
@ -1672,6 +1685,10 @@ module.exports = React.createClass({
</div>
);
}
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable";
if (isStatusAreaExpanded) {
statusBarAreaClass += " mx_RoomView_statusArea_expanded";
}
return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
@ -1694,7 +1711,7 @@ module.exports = React.createClass({
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
<div className="mx_RoomView_statusArea mx_fadable" style={{ opacity: this.props.opacity }}>
<div className={statusBarAreaClass} style={{opacity: this.props.opacity}}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }

View file

@ -23,11 +23,18 @@ var KeyCode = require('../../KeyCode');
var DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
const UNPAGINATION_PADDING = 1500;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
var debuglog = function() {};
}
/* This component implements an intelligent scrolling list.
@ -101,6 +108,17 @@ module.exports = React.createClass({
*/
onFillRequest: React.PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest: React.PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: React.PropTypes.func,
@ -124,6 +142,7 @@ module.exports = React.createClass({
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards) { return q(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {},
};
},
@ -226,6 +245,46 @@ module.exports = React.createClass({
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
},
// returns the vertical height in the given direction that can be removed from
// the content box (which has a height of scrollHeight, see checkFillState) without
// pagination occuring.
//
// padding* = UNPAGINATION_PADDING
//
// ### Region determined as excess.
//
// .---------. - -
// |#########| | |
// |#########| - | scrollTop |
// | | | padding* | |
// | | | | |
// .-+---------+-. - - | |
// : | | : | | |
// : | | : | clientHeight | |
// : | | : | | |
// .-+---------+-. - - |
// | | | | | |
// | | | | | clientHeight | scrollHeight
// | | | | | |
// `-+---------+-' - |
// : | | : | |
// : | | : | clientHeight |
// : | | : | |
// `-+---------+-' - - |
// | | | padding* |
// | | | |
// |#########| - |
// |#########| |
// `---------' -
_getExcessHeight: function(backwards) {
var sn = this._getScrollNode();
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
}
},
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
if (this.unmounted) {
@ -268,6 +327,55 @@ module.exports = React.createClass({
}
},
// check if unfilling is possible and send an unfill request if necessary
_checkUnfillState: function(backwards) {
let excessHeight = this._getExcessHeight(backwards);
if (excessHeight <= 0) {
return;
}
var itemlist = this.refs.itemlist;
var tiles = itemlist.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
// Subtract clientHeights to simulate the events being unpaginated whilst counting
// the events to be unpaginated.
if (backwards) {
// Iterate forwards from start of tiles, subtracting event tile height
let i = 0;
while (i < tiles.length && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i++;
}
} else {
// Iterate backwards from end of tiles, subtracting event tile height
let i = tiles.length - 1;
while (i > 0 && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i--;
}
}
if (markerScrollToken) {
// Use a debouncer to prevent multiple unfill calls in quick succession
// This is to make the unfilling process less aggressive
if (this._unfillDebouncer) {
clearTimeout(this._unfillDebouncer);
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
},
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
var dir = backwards ? 'b' : 'f';
@ -285,7 +393,7 @@ module.exports = React.createClass({
this._pendingFillRequests[dir] = true;
var fillPromise;
try {
fillPromise = this.props.onFillRequest(backwards);
fillPromise = this.props.onFillRequest(backwards);
} catch (e) {
this._pendingFillRequests[dir] = false;
throw e;
@ -294,6 +402,12 @@ module.exports = React.createClass({
q.finally(fillPromise, () => {
this._pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
@ -456,7 +570,7 @@ module.exports = React.createClass({
var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("Scrolling to token '" + node.dataset.scrollToken + "'+" +
debuglog("ScrollPanel: scrolling to token '" + node.dataset.scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) {
@ -468,7 +582,7 @@ module.exports = React.createClass({
_saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("Saved scroll state", this.scrollState);
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
return;
}
@ -486,13 +600,13 @@ module.exports = React.createClass({
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
}
debuglog("Saved scroll state", this.scrollState);
};
debuglog("ScrollPanel: saved scroll state", this.scrollState);
return;
}
}
debuglog("Unable to save scroll state: found no children in the viewport");
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
},
_restoreSavedScrollState: function() {
@ -526,7 +640,7 @@ module.exports = React.createClass({
this._lastSetScroll = scrollNode.scrollTop;
}
debuglog("Set scrollTop:", scrollNode.scrollTop,
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
},

View file

@ -38,7 +38,7 @@ if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
var debuglog = function() {};
}
/*
@ -108,7 +108,9 @@ var TimelinePanel = React.createClass({
getDefaultProps: function() {
return {
timelineCap: 250,
// By default, disable the timelineCap in favour of unpaginating based on
// event tile heights. (See _unpaginateEvents)
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
};
},
@ -245,6 +247,34 @@ var TimelinePanel = React.createClass({
}
},
onMessageListUnfillRequest: function(backwards, scrollToken) {
let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
debuglog("TimelinePanel: unpaginating events in direction", dir);
// All tiles are inserted by MessagePanel to have a scrollToken === eventId
let eventId = scrollToken;
let marker = this.state.events.findIndex(
(ev) => {
return ev.getId() === eventId;
}
);
let count = backwards ? marker + 1 : this.state.events.length - marker;
if (count > 0) {
debuglog("TimelinePanel: Unpaginating", count, "in direction", dir);
this._timelineWindow.unpaginate(count, backwards);
// We can now paginate in the unpaginated direction
const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate';
this.setState({
[canPaginateKey]: true,
events: this._getEvents(),
});
}
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
@ -292,7 +322,7 @@ var TimelinePanel = React.createClass({
});
},
onMessageListScroll: function () {
onMessageListScroll: function() {
if (this.props.onScroll) {
this.props.onScroll();
}
@ -357,7 +387,7 @@ var TimelinePanel = React.createClass({
// if we're at the end of the live timeline, append the pending events
if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.timelineSet.room.getPendingEvents());
events.push(...this.props.timelineSet.room.getPendingEvents());
}
var updatedState = {events: events};
@ -534,8 +564,9 @@ var TimelinePanel = React.createClass({
// first find where the current RM is
for (var i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId)
if (events[i].getId() == this.state.readMarkerEventId) {
break;
}
}
if (i >= events.length) {
return;
@ -614,7 +645,7 @@ var TimelinePanel = React.createClass({
var tl = this.props.timelineSet.getTimelineForEvent(rmId);
var rmTs;
if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId });
var event = tl.getEvents().find((e) => { return e.getId() == rmId; });
if (event) {
rmTs = event.getTs();
}
@ -780,7 +811,7 @@ var TimelinePanel = React.createClass({
});
};
}
var message = "Riot was trying to load a specific point in this room's timeline but ";
var message = "Tried to load a specific point in this room's timeline, but ";
if (error.errcode == 'M_FORBIDDEN') {
message += "you do not have permission to view the message in question.";
} else {
@ -791,7 +822,7 @@ var TimelinePanel = React.createClass({
description: message,
onFinished: onFinished,
});
}
};
var prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
@ -813,7 +844,7 @@ var TimelinePanel = React.createClass({
timelineLoading: true,
});
prom = prom.then(onLoaded, onError)
prom = prom.then(onLoaded, onError);
}
prom.done();
@ -838,7 +869,7 @@ var TimelinePanel = React.createClass({
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.timelineSet.getPendingEvents());
events.push(...this.props.timelineSet.getPendingEvents());
}
return events;
@ -900,8 +931,9 @@ var TimelinePanel = React.createClass({
_getCurrentReadReceipt: function(ignoreSynthesized) {
var client = MatrixClientPeg.get();
// the client can be null on logout
if (client == null)
if (client == null) {
return null;
}
var myUserId = client.credentials.userId;
return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
@ -984,6 +1016,7 @@ var TimelinePanel = React.createClass({
stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
onUnfillRequest={ this.onMessageListUnfillRequest }
opacity={ this.props.opacity }
className={ this.props.className }
tileShape={ this.props.tileShape }

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
// }];
if (uploads.length == 0) {
return <div />
return <div />;
}
var upload;
@ -68,7 +68,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
}
}
if (!upload) {
return <div />
return <div />;
}
var innerProgressStyle = {
@ -76,7 +76,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
};
var uploadedSize = filesize(upload.loaded);
var totalSize = filesize(upload.total);
if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) {
if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) {
uploadedSize = uploadedSize.replace(/ .*/, '');
}

View file

@ -26,12 +26,61 @@ var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
var SdkConfig = require('../../SdkConfig');
import AccessibleButton from '../views/elements/AccessibleButton';
// if this looks like a release, use the 'version' from package.json; else use
// the git sha.
const REACT_SDK_VERSION =
'dist' in package_json ? package_json.version : package_json.gitHead || "<local>";
// Enumerate some simple 'flip a bit' UI settings (if any).
// 'id' gives the key name in the im.vector.web.settings account data event
// 'label' is how we describe it in the UI.
const SETTINGS_LABELS = [
/*
{
id: 'alwaysShowTimestamps',
label: 'Always show message timestamps',
},
{
id: 'showTwelveHourTimestamps',
label: 'Show timestamps in 12 hour format (e.g. 2:30pm)',
},
{
id: 'useCompactLayout',
label: 'Use compact timeline layout',
},
{
id: 'useFixedWidthFont',
label: 'Use fixed width font',
},
*/
];
// Enumerate the available themes, with a nice human text label.
// 'id' gives the key name in the im.vector.web.settings account data event
// 'value' is the value for that key in the event
// 'label' is how we describe it in the UI.
//
// XXX: Ideally we would have a theme manifest or something and they'd be nicely
// packaged up in a single directory, and/or located at the application layer.
// But for now for expedience we just hardcode them here.
const THEMES = [
{
id: 'theme',
label: 'Light theme',
value: 'light',
},
{
id: 'theme',
label: 'Dark theme',
value: 'dark',
}
];
module.exports = React.createClass({
displayName: 'UserSettings',
@ -43,6 +92,9 @@ module.exports = React.createClass({
// True to show the 'labs' section of experimental features
enableLabs: React.PropTypes.bool,
// The base URL to use in the referral link. Defaults to window.location.origin.
referralBaseUrl: React.PropTypes.string,
// true if RightPanel is collapsed
collapsedRhs: React.PropTypes.bool,
},
@ -61,6 +113,7 @@ module.exports = React.createClass({
phase: "UserSettings.LOADING", // LOADING, DISPLAY
email_add_pending: false,
vectorVersion: null,
rejectingInvites: false,
};
},
@ -80,12 +133,24 @@ module.exports = React.createClass({
});
}
// Bulk rejecting invites:
// /sync won't have had time to return when UserSettings re-renders from state changes, so getRooms()
// will still return rooms with invites. To get around this, add a listener for
// membership updates and kick the UI.
MatrixClientPeg.get().on("RoomMember.membership", this._onInviteStateChange);
dis.dispatch({
action: 'ui_opacity',
sideOpacity: 0.3,
middleOpacity: 0.3,
});
this._refreshFromServer();
var syncedSettings = UserSettingsStore.getSyncedSettings();
if (!syncedSettings.theme) {
syncedSettings.theme = 'light';
}
this._syncedSettings = syncedSettings;
},
componentDidMount: function() {
@ -101,6 +166,10 @@ module.exports = React.createClass({
middleOpacity: 1.0,
});
dis.unregister(this.dispatcherRef);
let cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomMember.membership", this._onInviteStateChange);
}
},
_refreshFromServer: function() {
@ -164,8 +233,26 @@ module.exports = React.createClass({
},
onLogoutClicked: function(ev) {
var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt');
this.logoutModal = Modal.createDialog(LogoutPrompt);
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Sign out?",
description:
<div>
For security, logging out will delete any end-to-end encryption keys from this browser,
making previous encrypted chat history unreadable if you log back in.
In future this <a href="https://github.com/vector-im/riot-web/issues/2108">will be improved</a>,
but for now be warned.
</div>,
button: "Sign out",
onFinished: (confirmed) => {
if (confirmed) {
dis.dispatch({action: 'logout'});
if (this.props.onFinished) {
this.props.onFinished();
}
}
},
});
},
onPasswordChangeError: function(err) {
@ -237,6 +324,31 @@ module.exports = React.createClass({
this.setState({email_add_pending: true});
},
onRemoveThreepidClicked: function(threepid) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Remove Contact Information?",
description: "Remove " + threepid.address + "?",
button: 'Remove',
onFinished: (submit) => {
if (submit) {
this.setState({
phase: "UserSettings.LOADING",
});
MatrixClientPeg.get().deleteThreePid(threepid.medium, threepid.address).then(() => {
return this._refreshFromServer();
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Unable to remove contact information",
description: err.toString(),
});
}).done();
}
},
});
},
onEmailDialogFinished: function(ok) {
if (ok) {
this.verifyEmailAddress();
@ -257,8 +369,8 @@ module.exports = React.createClass({
this.setState({email_add_pending: false});
if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var message = "Unable to verify email address. "
message += "Please check your email and click on the link it contains. Once this is done, click continue."
var message = "Unable to verify email address. ";
message += "Please check your email and click on the link it contains. Once this is done, click continue.";
Modal.createDialog(QuestionDialog, {
title: "Verification Pending",
description: message,
@ -280,91 +392,185 @@ module.exports = React.createClass({
Modal.createDialog(DeactivateAccountDialog, {});
},
_onBugReportClicked: function() {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) {
return;
}
Modal.createDialog(BugReportDialog, {});
},
_onInviteStateChange: function(event, member, oldMembership) {
if (member.userId === this._me && oldMembership === "invite") {
this.forceUpdate();
}
},
_onRejectAllInvitesClicked: function(rooms, ev) {
this.setState({
rejectingInvites: true
});
// reject the invites
let promises = rooms.map((room) => {
return MatrixClientPeg.get().leave(room.roomId);
});
// purposefully drop errors to the floor: we'll just have a non-zero number on the UI
// after trying to reject all the invites.
q.allSettled(promises).then(() => {
this.setState({
rejectingInvites: false
});
}).done();
},
_onExportE2eKeysClicked: function() {
Modal.createDialogAsync(
(cb) => {
require.ensure(['../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
);
},
_onImportE2eKeysClicked: function() {
Modal.createDialogAsync(
(cb) => {
require.ensure(['../../async-components/views/dialogs/ImportE2eKeysDialog'], () => {
cb(require('../../async-components/views/dialogs/ImportE2eKeysDialog'));
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
);
},
_renderReferral: function() {
const teamToken = window.localStorage.getItem('mx_team_token');
if (!teamToken) {
return null;
}
if (typeof teamToken !== 'string') {
console.warn('Team token not a string');
return null;
}
const href = (this.props.referralBaseUrl || window.location.origin) +
`/#/register?referrer=${this._me}&team_token=${teamToken}`;
return (
<div>
<h3>Referral</h3>
<div className="mx_UserSettings_section">
Refer a friend to Riot: <a href={href}>{href}</a>
</div>
</div>
);
},
_renderUserInterfaceSettings: function() {
var client = MatrixClientPeg.get();
var settingsLabels = [
/*
{
id: 'alwaysShowTimestamps',
label: 'Always show message timestamps',
},
{
id: 'showTwelveHourTimestamps',
label: 'Show timestamps in 12 hour format (e.g. 2:30pm)',
},
{
id: 'useCompactLayout',
label: 'Use compact timeline layout',
},
{
id: 'useFixedWidthFont',
label: 'Use fixed width font',
},
*/
];
var syncedSettings = UserSettingsStore.getSyncedSettings();
return (
<div>
<h3>User Interface</h3>
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_toggle">
<input id="urlPreviewsDisabled"
type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
</label>
</div>
{ this._renderUrlPreviewSelector() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) }
</div>
{ settingsLabels.forEach( setting => {
<div className="mx_UserSettings_toggle">
<input id={ setting.id }
type="checkbox"
defaultChecked={ syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ settings.label }
</label>
</div>
})}
</div>
);
},
_renderUrlPreviewSelector: function() {
return <div className="mx_UserSettings_toggle">
<input id="urlPreviewsDisabled"
type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
</label>
</div>;
},
_renderSyncedSetting: function(setting) {
return <div className="mx_UserSettings_toggle" key={ setting.id }>
<input id={ setting.id }
type="checkbox"
defaultChecked={ this._syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ setting.label }
</label>
</div>;
},
_renderThemeSelector: function(setting) {
return <div className="mx_UserSettings_toggle" key={ setting.id + "_" + setting.value }>
<input id={ setting.id + "_" + setting.value }
type="radio"
name={ setting.id }
value={ setting.value }
defaultChecked={ this._syncedSettings[setting.id] === setting.value }
onChange={ e => {
if (e.target.checked) {
UserSettingsStore.setSyncedSetting(setting.id, setting.value);
}
dis.dispatch({
action: 'set_theme',
value: setting.value,
});
}
}
/>
<label htmlFor={ setting.id + "_" + setting.value }>
{ setting.label }
</label>
</div>;
},
_renderCryptoInfo: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
const client = MatrixClientPeg.get();
const deviceId = client.deviceId;
const identityKey = client.getDeviceEd25519Key() || "<not supported>";
let exportButton = null,
importButton = null;
if (client.isCryptoEnabled) {
exportButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</AccessibleButton>
);
importButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onImportE2eKeysClicked}>
Import E2E room keys
</AccessibleButton>
);
}
var client = MatrixClientPeg.get();
var deviceId = client.deviceId;
var identityKey = client.getDeviceEd25519Key() || "<not supported>";
var myDevice = client.getStoredDevicesForUser(MatrixClientPeg.get().credentials.userId)[0];
return (
<div>
<h3>Cryptography</h3>
<div className="mx_UserSettings_section mx_UserSettings_cryptoSection">
<ul>
<li><label>Device name:</label> <span>{ myDevice.getDisplayName() }</span></li>
<li><label>Device ID:</label> <span><code>{deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{identityKey}</b></code></span></li>
<li><label>Device ID:</label> <span><code>{deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{identityKey}</b></code></span></li>
</ul>
{exportButton}
{importButton}
</div>
</div>
);
},
_renderDevicesPanel: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var DevicesPanel = sdk.getComponent('settings.DevicesPanel');
return (
<div>
@ -374,7 +580,24 @@ module.exports = React.createClass({
);
},
_renderLabs: function () {
_renderBugReport: function() {
if (!SdkConfig.get().bug_report_endpoint_url) {
return <div />
}
return (
<div>
<h3>Bug Report</h3>
<div className="mx_UserSettings_section">
<p>Found a bug?</p>
<button className="mx_UserSettings_button danger"
onClick={this._onBugReportClicked}>Report it
</button>
</div>
</div>
);
},
_renderLabs: function() {
// default to enabled if undefined
if (this.props.enableLabs === false) return null;
@ -410,7 +633,7 @@ module.exports = React.createClass({
{features}
</div>
</div>
)
);
},
_renderDeactivateAccount: function() {
@ -420,15 +643,49 @@ module.exports = React.createClass({
return <div>
<h3>Deactivate Account</h3>
<div className="mx_UserSettings_section">
<button className="mx_UserSettings_button danger"
<AccessibleButton className="mx_UserSettings_button danger"
onClick={this._onDeactivateAccountClicked}>Deactivate my account
</button>
</AccessibleButton>
</div>
</div>;
},
_renderBulkOptions: function() {
let invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => {
return r.hasMembershipState(this._me, "invite");
});
if (invitedRooms.length === 0) {
return null;
}
let Spinner = sdk.getComponent("elements.Spinner");
let reject = <Spinner />;
if (!this.state.rejectingInvites) {
// bind() the invited rooms so any new invites that may come in as this button is clicked
// don't inadvertently get rejected as well.
reject = (
<AccessibleButton className="mx_UserSettings_button danger"
onClick={this._onRejectAllInvitesClicked.bind(this, invitedRooms)}>
Reject all {invitedRooms.length} invites
</AccessibleButton>
);
}
return <div>
<h3>Bulk Options</h3>
<div className="mx_UserSettings_section">
{reject}
</div>
</div>;
},
nameForMedium: function(medium) {
if (medium == 'msisdn') return 'Phone';
return medium[0].toUpperCase() + medium.slice(1);
},
render: function() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) {
case "UserSettings.LOADING":
@ -452,15 +709,18 @@ module.exports = React.createClass({
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
);
var threepidsSection = this.state.threepids.map(function(val, pidIndex) {
var id = "email-" + val.address;
var threepidsSection = this.state.threepids.map((val, pidIndex) => {
const id = "3pid-" + val.address;
return (
<div className="mx_UserSettings_profileTableRow" key={pidIndex}>
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor={id}>Email</label>
<label htmlFor={id}>{this.nameForMedium(val.medium)}</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled />
<input type="text" key={val.address} id={id} value={val.address} disabled />
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/cancel-small.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />
</div>
</div>
);
@ -482,7 +742,7 @@ module.exports = React.createClass({
blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } />
</div>
<div className="mx_UserSettings_addThreepid">
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ this.onAddThreepidClicked.bind(this, undefined, true) }/>
</div>
</div>
@ -563,7 +823,7 @@ module.exports = React.createClass({
</div>
<div className="mx_UserSettings_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
<img src="img/camera.svg"
<img src="img/camera.svg" className="mx_filterFlipColor"
alt="Upload avatar" title="Upload avatar"
width="17" height="15" />
</label>
@ -576,19 +836,23 @@ module.exports = React.createClass({
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
<AccessibleButton className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
Sign out
</div>
</AccessibleButton>
{accountJsx}
</div>
{this._renderReferral()}
{notification_area}
{this._renderUserInterfaceSettings()}
{this._renderLabs()}
{this._renderDevicesPanel()}
{this._renderCryptoInfo()}
{this._renderBulkOptions()}
{this._renderBugReport()}
<h3>Advanced</h3>

View file

@ -58,7 +58,7 @@ module.exports = React.createClass({
this.setState({
progress: null
});
})
});
},
onVerify: function(ev) {
@ -71,7 +71,7 @@ module.exports = React.createClass({
this.setState({ progress: "complete" });
}, (err) => {
this.showErrorDialog(err.message);
})
});
},
onSubmitForm: function(ev) {
@ -87,10 +87,26 @@ module.exports = React.createClass({
this.showErrorDialog("New passwords must match each other.");
}
else {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password
);
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Warning",
description:
<div>
Resetting password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">may be improved</a>,
but for now be warned.
</div>,
button: "Continue",
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password
);
}
},
});
}
},
@ -129,7 +145,7 @@ module.exports = React.createClass({
var resetPasswordJsx;
if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner />
resetPasswordJsx = <Spinner />;
}
else if (this.state.progress === "sent_email") {
resetPasswordJsx = (

View file

@ -173,7 +173,7 @@ module.exports = React.createClass({
},
_getCurrentFlowStep: function() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
},
_setStateFromError: function(err, isLoginAttempt) {
@ -195,7 +195,7 @@ module.exports = React.createClass({
}
let errorText = "Error: Problem communicating with the given homeserver " +
(errCode ? "(" + errCode + ")" : "")
(errCode ? "(" + errCode + ")" : "");
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
@ -203,7 +203,7 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http")))
{
errorText = <span>
Can't connect to homeserver via HTTP when using Riot served by HTTPS.
Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar.
Either use HTTPS or <a href='https://www.google.com/search?&q=enable%20unsafe%20scripts'>enable unsafe scripts</a>
</span>;
}
@ -258,7 +258,7 @@ module.exports = React.createClass({
loginAsGuestJsx =
<a className="mx_Login_create" onClick={this._onLoginAsGuestClick} href="#">
Login as guest
</a>
</a>;
}
var returnToAppJsx;
@ -266,7 +266,7 @@ module.exports = React.createClass({
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>
</a>;
}
return (

View file

@ -25,6 +25,7 @@ var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm");
var RtsClient = require("../../../RtsClient");
var MIN_PASSWORD_LENGTH = 6;
@ -47,8 +48,16 @@ module.exports = React.createClass({
defaultIsUrl: React.PropTypes.string,
brand: React.PropTypes.string,
email: React.PropTypes.string,
referrer: React.PropTypes.string,
username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string,
teamServerConfig: React.PropTypes.shape({
// Email address to request new teams
supportEmail: React.PropTypes.string.isRequired,
// URL of the riot-team-server to get team configurations and track referrals
teamServerURL: React.PropTypes.string.isRequired,
}),
teamSelected: React.PropTypes.object,
defaultDeviceDisplayName: React.PropTypes.string,
@ -60,6 +69,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
busy: false,
teamServerBusy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
@ -75,6 +85,7 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._unmounted = false;
this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI
this.registerLogic = new Signup.Register(
@ -88,10 +99,40 @@ module.exports = React.createClass({
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState();
if (
this.props.teamServerConfig &&
this.props.teamServerConfig.teamServerURL &&
!this._rtsClient
) {
this._rtsClient = new RtsClient(this.props.teamServerConfig.teamServerURL);
this.setState({
teamServerBusy: true,
});
// GET team configurations including domains, names and icons
this._rtsClient.getTeamsConfig().then((data) => {
const teamsConfig = {
teams: data,
supportEmail: this.props.teamServerConfig.supportEmail,
};
console.log('Setting teams config to ', teamsConfig);
this.setState({
teamsConfig: teamsConfig,
teamServerBusy: false,
});
}, (err) => {
console.error('Error retrieving config for teams', err);
this.setState({
teamServerBusy: false,
});
});
}
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
this._unmounted = true;
},
componentDidMount: function() {
@ -169,6 +210,43 @@ module.exports = React.createClass({
accessToken: response.access_token
});
if (
self._rtsClient &&
self.props.referrer &&
self.state.teamSelected
) {
// Track referral, get team_token in order to retrieve team config
self._rtsClient.trackReferral(
self.props.referrer,
response.user_id,
self.state.formVals.email
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
self._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`
);
if (!team.rooms) {
return;
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.room_id);
}
});
}, (err) => {
console.error('Error getting team config', err);
});
}, (err) => {
console.error('Error tracking referral', err);
});
}
if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers;
@ -238,7 +316,15 @@ module.exports = React.createClass({
});
},
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() {
const Spinner = sdk.getComponent("elements.Spinner");
var currStep = this.registerLogic.getStep();
var registerStep;
switch (currStep) {
@ -248,16 +334,23 @@ module.exports = React.createClass({
case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
}
registerStep = (
<RegistrationForm
showEmail={true}
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} />
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
);
break;
case "Register.STEP_m.login.email.identity":
@ -273,6 +366,7 @@ module.exports = React.createClass({
if (serverParams && serverParams["m.login.recaptcha"]) {
publicKey = serverParams["m.login.recaptcha"].public_key;
}
registerStep = (
<CaptchaForm sitePublicKey={publicKey}
onCaptchaResponse={this.onCaptchaResponse}
@ -285,7 +379,6 @@ module.exports = React.createClass({
}
var busySpinner;
if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = (
<Spinner />
);
@ -296,7 +389,7 @@ module.exports = React.createClass({
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>
</a>;
}
return (
@ -330,7 +423,7 @@ module.exports = React.createClass({
return (
<div className="mx_Login">
<div className="mx_Login_box">
<LoginHeader />
<LoginHeader icon={this.state.teamSelected ? this.state.teamSelected.icon : null}/>
{this._getRegisterContentJsx()}
<LoginFooter />
</div>

View file

@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var AvatarLogic = require("../../../Avatar");
import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'BaseAvatar',
@ -41,7 +42,7 @@ module.exports = React.createClass({
height: 40,
resizeMethod: 'crop',
defaultToInitialLetter: true
}
};
},
getInitialState: function() {
@ -138,7 +139,7 @@ module.exports = React.createClass({
const {
name, idName, title, url, urls, width, height, resizeMethod,
defaultToInitialLetter, viewUserOnClick,
defaultToInitialLetter, onClick,
...otherProps
} = this.props;
@ -156,12 +157,24 @@ module.exports = React.createClass({
</span>
);
}
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
if (onClick != null) {
return (
<AccessibleButton className="mx_BaseAvatar" onClick={onClick}>
<img className="mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
</AccessibleButton>
);
} else {
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
}
}
});

View file

@ -33,6 +33,7 @@ module.exports = React.createClass({
onClick: React.PropTypes.func,
// Whether the onClick of the avatar should be overriden to dispatch 'view_user'
viewUserOnClick: React.PropTypes.bool,
title: React.PropTypes.string,
},
getDefaultProps: function() {
@ -41,7 +42,7 @@ module.exports = React.createClass({
height: 40,
resizeMethod: 'crop',
viewUserOnClick: false,
}
};
},
getInitialState: function() {
@ -58,26 +59,26 @@ module.exports = React.createClass({
}
return {
name: props.member.name,
title: props.member.userId,
title: props.title || props.member.userId,
imageUrl: Avatar.avatarUrlForMember(props.member,
props.width,
props.height,
props.resizeMethod)
}
};
},
render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var {member, onClick, ...otherProps} = this.props;
var {member, onClick, viewUserOnClick, ...otherProps} = this.props;
if (this.props.viewUserOnClick) {
if (viewUserOnClick) {
onClick = () => {
dispatcher.dispatch({
action: 'view_user',
member: this.props.member,
});
}
};
}
return (

View file

@ -39,7 +39,7 @@ module.exports = React.createClass({
height: 36,
resizeMethod: 'crop',
oobData: {},
}
};
},
getInitialState: function() {
@ -51,7 +51,7 @@ module.exports = React.createClass({
componentWillReceiveProps: function(newProps) {
this.setState({
urls: this.getImageUrls(newProps)
})
});
},
getImageUrls: function(props) {

View file

@ -40,7 +40,7 @@ module.exports = React.createClass({
},
onValueChanged: function(ev) {
this.props.onChange(ev.target.value)
this.props.onChange(ev.target.value);
},
render: function() {

View file

@ -0,0 +1,72 @@
/*
Copyright 2017 Vector Creations 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 * as KeyCode from '../../../KeyCode';
/**
* Basic container for modal dialogs.
*
* Includes a div for the title, and a keypress handler which cancels the
* dialog on escape.
*/
export default React.createClass({
displayName: 'BaseDialog',
propTypes: {
// onFinished callback to call when Escape is pressed
onFinished: React.PropTypes.func.isRequired,
// callback to call when Enter is pressed
onEnterPressed: React.PropTypes.func,
// CSS class to apply to dialog div
className: React.PropTypes.string,
// Title for the dialog.
// (could probably actually be something more complicated than a string if desired)
title: React.PropTypes.string.isRequired,
// children should be the content of the dialog
children: React.PropTypes.node,
},
_onKeyDown: function(e) {
if (e.keyCode === KeyCode.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.props.onFinished();
} else if (e.keyCode === KeyCode.ENTER) {
if (this.props.onEnterPressed) {
e.stopPropagation();
e.preventDefault();
this.props.onEnterPressed(e);
}
}
},
render: function() {
return (
<div onKeyDown={this._onKeyDown} className={this.props.className}>
<div className='mx_Dialog_title'>
{ this.props.title }
</div>
{ this.props.children }
</div>
);
},
});

View file

@ -14,19 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var classNames = require('classnames');
var sdk = require("../../../index");
var Invite = require("../../../Invite");
var createRoom = require("../../../createRoom");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var DMRoomMap = require('../../../utils/DMRoomMap');
var rate_limited_func = require("../../../ratelimitedfunc");
var dis = require("../../../dispatcher");
var Modal = require('../../../Modal');
import React from 'react';
import classNames from 'classnames';
import sdk from '../../../index';
import { getAddressType, inviteMultipleToRoom } from '../../../Invite';
import createRoom from '../../../createRoom';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import rate_limited_func from '../../../ratelimitedfunc';
import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
import q from 'q';
const TRUNCATE_QUERY_LIST = 40;
/*
* Escapes a string so it can be used in a RegExp
* Basically just replaces: \ ^ $ * + ? . ( ) | { } [ ]
* From http://stackoverflow.com/a/6969486
*/
function escapeRegExp(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}
module.exports = React.createClass({
displayName: "ChatInviteDialog",
propTypes: {
@ -48,7 +59,7 @@ module.exports = React.createClass({
title: "Start a chat",
description: "Who would you like to communicate with?",
value: "",
placeholder: "User ID, Name or email",
placeholder: "Email, name or matrix ID",
button: "Start Chat",
focus: true
};
@ -57,7 +68,14 @@ module.exports = React.createClass({
getInitialState: function() {
return {
error: false,
// List of AddressTile.InviteAddressType objects represeting
// the list of addresses we're going to invite
inviteList: [],
// List of AddressTile.InviteAddressType objects represeting
// the set of autocompletion results for the current search
// query.
queryList: [],
};
},
@ -71,15 +89,12 @@ module.exports = React.createClass({
},
onButtonClick: function() {
var inviteList = this.state.inviteList.slice();
let inviteList = this.state.inviteList.slice();
// Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local inviteList
var check = Invite.isValidAddress(this.refs.textinput.value);
if (check === true || check === null) {
inviteList.push(this.refs.textinput.value);
} else if (this.refs.textinput.value.length > 0) {
this.setState({ error: true });
return;
if (this.refs.textinput.value !== '') {
inviteList = this._addInputToList();
if (inviteList === null) return;
}
if (inviteList.length > 0) {
@ -119,15 +134,15 @@ module.exports = React.createClass({
} else if (e.keyCode === 38) { // up arrow
e.stopPropagation();
e.preventDefault();
this.addressSelector.onKeyUp();
this.addressSelector.moveSelectionUp();
} else if (e.keyCode === 40) { // down arrow
e.stopPropagation();
e.preventDefault();
this.addressSelector.onKeyDown();
} else if (this.state.queryList.length > 0 && (e.keyCode === 188, e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
this.addressSelector.moveSelectionDown();
} else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
e.stopPropagation();
e.preventDefault();
this.addressSelector.onKeySelect();
this.addressSelector.chooseSelection();
} else if (this.refs.textinput.value.length === 0 && this.state.inviteList.length && e.keyCode === 8) { // backspace
e.stopPropagation();
e.preventDefault();
@ -135,33 +150,56 @@ module.exports = React.createClass({
} else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
this.onButtonClick();
if (this.refs.textinput.value == '') {
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
this._addInputToList();
}
} else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab
e.stopPropagation();
e.preventDefault();
var check = Invite.isValidAddress(this.refs.textinput.value);
if (check === true || check === null) {
var inviteList = this.state.inviteList.slice();
inviteList.push(this.refs.textinput.value.trim());
this.setState({
inviteList: inviteList,
queryList: [],
});
} else {
this.setState({ error: true });
}
this._addInputToList();
}
},
onQueryChanged: function(ev) {
var query = ev.target.value;
var queryList = [];
const query = ev.target.value;
let queryList = [];
// Only do search if there is something to search
if (query.length > 0) {
if (query.length > 0 && query != '@') {
// filter the known users list
queryList = this._userList.filter((user) => {
return this._matches(query, user);
}).map((user) => {
// Return objects, structure of which is defined
// by InviteAddressType
return {
addressType: 'mx',
address: user.userId,
displayName: user.displayName,
avatarMxc: user.avatarUrl,
isKnown: true,
}
});
// If the query isn't a user we know about, but is a
// valid address, add an entry for that
if (queryList.length == 0) {
const addrType = getAddressType(query);
if (addrType !== null) {
queryList[0] = {
addressType: addrType,
address: query,
isKnown: false,
};
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') {
this._lookupThreepid(addrType, query).done();
}
}
}
}
this.setState({
@ -179,7 +217,8 @@ module.exports = React.createClass({
inviteList: inviteList,
queryList: [],
});
}
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
};
},
onClick: function(index) {
@ -191,11 +230,12 @@ module.exports = React.createClass({
onSelected: function(index) {
var inviteList = this.state.inviteList.slice();
inviteList.push(this.state.queryList[index].userId);
inviteList.push(this.state.queryList[index]);
this.setState({
inviteList: inviteList,
queryList: [],
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
},
_getDirectMessageRoom: function(addr) {
@ -226,10 +266,14 @@ module.exports = React.createClass({
return;
}
const addrTexts = addrs.map((addr) => {
return addr.address;
});
if (this.props.roomId) {
// Invite new user to a room
var self = this;
Invite.inviteMultipleToRoom(this.props.roomId, addrs)
inviteMultipleToRoom(this.props.roomId, addrTexts)
.then(function(addrs) {
var room = MatrixClientPeg.get().getRoom(self.props.roomId);
return self._showAnyInviteErrors(addrs, room);
@ -244,9 +288,9 @@ module.exports = React.createClass({
return null;
})
.done();
} else if (this._isDmChat(addrs)) {
} else if (this._isDmChat(addrTexts)) {
// Start the DM chat
createRoom({dmUserId: addrs[0]})
createRoom({dmUserId: addrTexts[0]})
.catch(function(err) {
console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -263,7 +307,7 @@ module.exports = React.createClass({
var room;
createRoom().then(function(roomId) {
room = MatrixClientPeg.get().getRoom(roomId);
return Invite.inviteMultipleToRoom(roomId, addrs);
return inviteMultipleToRoom(roomId, addrTexts);
})
.then(function(addrs) {
return self._showAnyInviteErrors(addrs, room);
@ -281,7 +325,7 @@ module.exports = React.createClass({
}
// Close - this will happen before the above, as that is async
this.props.onFinished(true, addrs);
this.props.onFinished(true, addrTexts);
},
_updateUserList: new rate_limited_func(function() {
@ -315,19 +359,27 @@ module.exports = React.createClass({
return true;
}
// split spaces in name and try matching constituent parts
var parts = name.split(" ");
for (var i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) {
return true;
}
// Try to find the query following a "word boundary", except that
// this does avoids using \b because it only considers letters from
// the roman alphabet to be word characters.
// Instead, we look for the query following either:
// * The start of the string
// * Whitespace, or
// * A fixed number of punctuation characters
const expr = new RegExp("(?:^|[\\s\\(\)'\",\.-_@\?;:{}\\[\\]\\#~`\\*\\&\\$])" + escapeRegExp(query));
if (expr.test(name)) {
return true;
}
return false;
},
_isOnInviteList: function(uid) {
for (let i = 0; i < this.state.inviteList.length; i++) {
if (this.state.inviteList[i].toLowerCase() === uid) {
if (
this.state.inviteList[i].addressType == 'mx' &&
this.state.inviteList[i].address.toLowerCase() === uid
) {
return true;
}
}
@ -335,7 +387,7 @@ module.exports = React.createClass({
},
_isDmChat: function(addrs) {
if (addrs.length === 1 && Invite.getAddressType(addrs[0]) === "mx" && !this.props.roomId) {
if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) {
return true;
} else {
return false;
@ -361,9 +413,74 @@ module.exports = React.createClass({
return addrs;
},
_addInputToList: function() {
const addressText = this.refs.textinput.value.trim();
const addrType = getAddressType(addressText);
const addrObj = {
addressType: addrType,
address: addressText,
isKnown: false,
};
if (addrType == null) {
this.setState({ error: true });
return null;
} else if (addrType == 'mx') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
if (user) {
addrObj.displayName = user.displayName;
addrObj.avatarMxc = user.avatarUrl;
addrObj.isKnown = true;
}
}
const inviteList = this.state.inviteList.slice();
inviteList.push(addrObj);
this.setState({
inviteList: inviteList,
queryList: [],
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
return inviteList;
},
_lookupThreepid: function(medium, address) {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's
// not like they leak.
this._cancelThreepidLookup = function() {
cancelled = true;
}
// wait a bit to let the user finish typing
return q.delay(500).then(() => {
if (cancelled) return null;
return MatrixClientPeg.get().lookupThreePid(medium, address);
}).then((res) => {
if (res === null || !res.mxid) return null;
if (cancelled) return null;
return MatrixClientPeg.get().getProfileInfo(res.mxid);
}).then((res) => {
if (res === null) return null;
if (cancelled) return null;
this.setState({
queryList: [{
// an InviteAddressType
addressType: medium,
address: address,
displayName: res.displayname,
avatarMxc: res.avatar_url,
isKnown: true,
}]
});
});
},
render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var AddressSelector = sdk.getComponent("elements.AddressSelector");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
var query = [];
@ -394,13 +511,18 @@ module.exports = React.createClass({
var error;
var addressSelector;
if (this.state.error) {
error = <div className="mx_ChatInviteDialog_error">You have entered an invalid contact. Try using their Matrix ID or email address.</div>
error = <div className="mx_ChatInviteDialog_error">You have entered an invalid contact. Try using their Matrix ID or email address.</div>;
} else {
const addressSelectorHeader = <div className="mx_ChatInviteDialog_addressSelectHeader">
Searching known users
</div>;
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref}}
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
addressList={ this.state.queryList }
onSelected={ this.onSelected }
truncateAt={ TRUNCATE_QUERY_LIST } />
truncateAt={ TRUNCATE_QUERY_LIST }
header={ addressSelectorHeader }
/>
);
}
@ -409,9 +531,10 @@ module.exports = React.createClass({
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_ChatInviteDialog_cancel" onClick={this.onCancel} >
<AccessibleButton className="mx_ChatInviteDialog_cancel"
onClick={this.onCancel} >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</div>
</AccessibleButton>
<div className="mx_ChatInviteDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>

View file

@ -80,8 +80,8 @@ export default class DeactivateAccountDialog extends React.Component {
let error = null;
if (this.state.errStr) {
error = <div className="error">
{this.state.err_str}
</div>
{this.state.errStr}
</div>;
passwordBoxClass = 'error';
}
@ -92,7 +92,7 @@ export default class DeactivateAccountDialog extends React.Component {
if (!this.state.busy) {
cancelButton = <button onClick={this._onCancel} autoFocus={true}>
Cancel
</button>
</button>;
}
return (

View file

@ -1,187 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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.
*/
var React = require("react");
var sdk = require('../../../index');
var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'EncryptedEventDialog',
propTypes: {
event: React.PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return { device: this.refreshDevice() };
},
componentWillMount: function() {
this._unmounted = false;
var client = MatrixClientPeg.get();
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
// no need to redownload keys if we already have the device
if (this.state.device) {
return;
}
client.downloadKeys([this.props.event.getSender()], true).done(()=>{
if (this._unmounted) {
return;
}
this.setState({ device: this.refreshDevice() });
}, (err)=>{
console.log("Error downloading devices", err);
});
},
componentWillUnmount: function() {
this._unmounted = true;
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
}
},
refreshDevice: function() {
return MatrixClientPeg.get().getEventSenderDeviceInfo(this.props.event);
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.event.getSender()) {
this.setState({ device: this.refreshDevice() });
}
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
},
_renderDeviceInfo: function() {
var device = this.state.device;
if (!device) {
return (<i>unknown device</i>);
}
var verificationStatus = (<b>NOT verified</b>);
if (device.isBlocked()) {
verificationStatus = (<b>Blocked</b>);
} else if (device.isVerified()) {
verificationStatus = "verified";
}
return (
<table>
<tbody>
<tr>
<td>Name</td>
<td>{ device.getDisplayName() }</td>
</tr>
<tr>
<td>Device ID</td>
<td><code>{ device.deviceId }</code></td>
</tr>
<tr>
<td>Verification</td>
<td>{ verificationStatus }</td>
</tr>
<tr>
<td>Ed25519 fingerprint</td>
<td><code>{device.getFingerprint()}</code></td>
</tr>
</tbody>
</table>
);
},
_renderEventInfo: function() {
var event = this.props.event;
return (
<table>
<tbody>
<tr>
<td>User ID</td>
<td>{ event.getSender() }</td>
</tr>
<tr>
<td>Curve25519 identity key</td>
<td><code>{ event.getSenderKey() || <i>none</i> }</code></td>
</tr>
<tr>
<td>Claimed Ed25519 fingerprint key</td>
<td><code>{ event.getKeysClaimed().ed25519 || <i>none</i> }</code></td>
</tr>
<tr>
<td>Algorithm</td>
<td>{ event.getWireContent().algorithm || <i>unencrypted</i> }</td>
</tr>
{
event.getContent().msgtype === 'm.bad.encrypted' ? (
<tr>
<td>Decryption error</td>
<td>{ event.getContent().body }</td>
</tr>
) : null
}
<tr>
<td>Session ID</td>
<td><code>{ event.getWireContent().session_id || <i>none</i> }</code></td>
</tr>
</tbody>
</table>
);
},
render: function() {
var DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
var buttons = null;
if (this.state.device) {
buttons = (
<DeviceVerifyButtons device={ this.state.device }
userId={ this.props.event.getSender() }
/>
);
}
return (
<div className="mx_EncryptedEventDialog" onKeyDown={ this.onKeyDown }>
<div className="mx_Dialog_title">
End-to-end encryption information
</div>
<div className="mx_Dialog_content">
<h4>Event information</h4>
{this._renderEventInfo()}
<h4>Sender device information</h4>
{this._renderDeviceInfo()}
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={ this.props.onFinished } autoFocus={ true }>
OK
</button>
{buttons}
</div>
</div>
);
}
});

View file

@ -25,9 +25,10 @@ limitations under the License.
* });
*/
var React = require("react");
import React from 'react';
import sdk from '../../../index';
module.exports = React.createClass({
export default React.createClass({
displayName: 'ErrorDialog',
propTypes: {
title: React.PropTypes.string,
@ -49,20 +50,11 @@ module.exports = React.createClass({
};
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_ErrorDialog" onKeyDown={ this.onKeyDown }>
<div className="mx_Dialog_title">
{this.props.title}
</div>
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={this.props.title}>
<div className="mx_Dialog_content">
{this.props.description}
</div>
@ -71,7 +63,7 @@ module.exports = React.createClass({
{this.props.button}
</button>
</div>
</div>
</BaseDialog>
);
}
},
});

View file

@ -111,20 +111,9 @@ export default React.createClass({
});
},
_onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
if (!this.state.busy) {
this._onCancel();
}
}
else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
if (this.state.submitButtonEnabled && !this.state.busy) {
this._onSubmit();
}
_onEnterPressed: function(e) {
if (this.state.submitButtonEnabled && !this.state.busy) {
this._onSubmit();
}
},
@ -171,6 +160,7 @@ export default React.createClass({
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let error = null;
if (this.state.errorText) {
@ -200,10 +190,11 @@ export default React.createClass({
);
return (
<div className="mx_InteractiveAuthDialog" onKeyDown={this._onKeyDown}>
<div className="mx_Dialog_title">
{this.props.title}
</div>
<BaseDialog className="mx_InteractiveAuthDialog"
onEnterPressed={this._onEnterPressed}
onFinished={this.props.onFinished}
title={this.props.title}
>
<div className="mx_Dialog_content">
<p>This operation requires additional authentication.</p>
{this._renderCurrentStage()}
@ -213,7 +204,7 @@ export default React.createClass({
{submitButton}
{cancelButton}
</div>
</div>
</BaseDialog>
);
},
});

View file

@ -1,61 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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.
*/
var React = require('react');
var dis = require("../../../dispatcher");
module.exports = React.createClass({
displayName: 'LogoutPrompt',
propTypes: {
onFinished: React.PropTypes.func,
},
logOut: function() {
dis.dispatch({action: 'logout'});
if (this.props.onFinished) {
this.props.onFinished();
}
},
cancelPrompt: function() {
if (this.props.onFinished) {
this.props.onFinished();
}
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.cancelPrompt();
}
},
render: function() {
return (
<div>
<div className="mx_Dialog_content">
Sign out?
</div>
<div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }>
<button className="mx_Dialog_primary" autoFocus onClick={this.logOut}>Sign Out</button>
<button onClick={this.cancelPrompt}>Cancel</button>
</div>
</div>
);
}
});

View file

@ -23,8 +23,9 @@ limitations under the License.
* });
*/
var React = require("react");
var dis = require("../../../dispatcher");
import React from 'react';
import dis from '../../../dispatcher';
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'NeedToRegisterDialog',
@ -54,11 +55,12 @@ module.exports = React.createClass({
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_NeedToRegisterDialog">
<div className="mx_Dialog_title">
{this.props.title}
</div>
<BaseDialog className="mx_NeedToRegisterDialog"
onFinished={this.props.onFinished}
title={this.props.title}
>
<div className="mx_Dialog_content">
{this.props.description}
</div>
@ -70,7 +72,7 @@ module.exports = React.createClass({
Register
</button>
</div>
</div>
</BaseDialog>
);
}
},
});

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
import React from 'react';
import sdk from '../../../index';
module.exports = React.createClass({
export default React.createClass({
displayName: 'QuestionDialog',
propTypes: {
title: React.PropTypes.string,
@ -46,25 +47,13 @@ module.exports = React.createClass({
this.props.onFinished(false);
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
this.props.onFinished(true);
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_QuestionDialog" onKeyDown={ this.onKeyDown }>
<div className="mx_Dialog_title">
{this.props.title}
</div>
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
onEnterPressed={ this.onOk }
title={this.props.title}
>
<div className="mx_Dialog_content">
{this.props.description}
</div>
@ -77,7 +66,7 @@ module.exports = React.createClass({
Cancel
</button>
</div>
</div>
</BaseDialog>
);
}
},
});

View file

@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var sdk = require("../../../index.js");
var MatrixClientPeg = require("../../../MatrixClientPeg");
import React from 'react';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
module.exports = React.createClass({
/**
* Prompt the user to set a display name.
*
* On success, `onFinished(true, newDisplayName)` is called.
*/
export default React.createClass({
displayName: 'SetDisplayNameDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
@ -42,10 +47,6 @@ module.exports = React.createClass({
this.refs.input_value.select();
},
getValue: function() {
return this.state.value;
},
onValueChange: function(ev) {
this.setState({
value: ev.target.value
@ -54,16 +55,17 @@ module.exports = React.createClass({
onFormSubmit: function(ev) {
ev.preventDefault();
this.props.onFinished(true);
this.props.onFinished(true, this.state.value);
return false;
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_SetDisplayNameDialog">
<div className="mx_Dialog_title">
Set a Display Name
</div>
<BaseDialog className="mx_SetDisplayNameDialog"
onFinished={this.props.onFinished}
title="Set a Display Name"
>
<div className="mx_Dialog_content">
Your display name is how you'll appear to others when you speak in rooms.<br/>
What would you like it to be?
@ -79,7 +81,7 @@ module.exports = React.createClass({
<input className="mx_Dialog_primary" type="submit" value="Set" />
</div>
</form>
</div>
</BaseDialog>
);
}
},
});

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
import React from 'react';
import sdk from '../../../index';
module.exports = React.createClass({
export default React.createClass({
displayName: 'TextInputDialog',
propTypes: {
title: React.PropTypes.string,
@ -27,7 +28,7 @@ module.exports = React.createClass({
value: React.PropTypes.string,
button: React.PropTypes.string,
focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired
onFinished: React.PropTypes.func.isRequired,
},
getDefaultProps: function() {
@ -36,7 +37,7 @@ module.exports = React.createClass({
value: "",
description: "",
button: "OK",
focus: true
focus: true,
};
},
@ -55,25 +56,13 @@ module.exports = React.createClass({
this.props.onFinished(false);
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
this.props.onFinished(true, this.refs.textinput.value);
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_TextInputDialog">
<div className="mx_Dialog_title">
{this.props.title}
</div>
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> {this.props.description} </label>
@ -90,7 +79,7 @@ module.exports = React.createClass({
{this.props.button}
</button>
</div>
</div>
</BaseDialog>
);
}
},
});

View file

@ -0,0 +1,54 @@
/*
Copyright 2016 Jani Mustonen
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';
/**
* AccessibleButton is a generic wrapper for any element that should be treated
* as a button. Identifies the element as a button, setting proper tab
* indexing and keyboard activation behavior.
*
* @param {Object} props react element properties
* @returns {Object} rendered react
*/
export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick;
restProps.onKeyDown = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick();
};
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";
return React.createElement(element, restProps, children);
}
/**
* children: React's magic prop. Represents all children given to the element.
* element: (optional) The base element type. "div" by default.
* onClick: (required) Event handler for button activation. Should be
* implemented exactly like a normal onClick handler.
*/
AccessibleButton.propTypes = {
children: React.PropTypes.node,
element: React.PropTypes.string,
onClick: React.PropTypes.func.isRequired,
};
AccessibleButton.defaultProps = {
element: 'div',
};
AccessibleButton.displayName = "AccessibleButton";

View file

@ -16,18 +16,24 @@ limitations under the License.
'use strict';
var React = require("react");
var sdk = require("../../../index");
var classNames = require('classnames');
import React from 'react';
import sdk from '../../../index';
import classNames from 'classnames';
import { InviteAddressType } from './AddressTile';
module.exports = React.createClass({
export default React.createClass({
displayName: 'AddressSelector',
propTypes: {
onSelected: React.PropTypes.func.isRequired,
addressList: React.PropTypes.array.isRequired,
// List of the addresses to display
addressList: React.PropTypes.arrayOf(InviteAddressType).isRequired,
truncateAt: React.PropTypes.number.isRequired,
selected: React.PropTypes.number,
// Element to put as a header on top of the list
header: React.PropTypes.node,
},
getInitialState: function() {
@ -55,7 +61,7 @@ module.exports = React.createClass({
}
},
onKeyUp: function() {
moveSelectionUp: function() {
if (this.state.selected > 0) {
this.setState({
selected: this.state.selected - 1,
@ -64,7 +70,7 @@ module.exports = React.createClass({
}
},
onKeyDown: function() {
moveSelectionDown: function() {
if (this.state.selected < this._maxSelected(this.props.addressList)) {
this.setState({
selected: this.state.selected + 1,
@ -73,25 +79,19 @@ module.exports = React.createClass({
}
},
onKeySelect: function() {
chooseSelection: function() {
this.selectAddress(this.state.selected);
},
onClick: function(index) {
var self = this;
return function() {
self.selectAddress(index);
};
this.selectAddress(index);
},
onMouseEnter: function(index) {
var self = this;
return function() {
self.setState({
selected: index,
hover: true,
});
};
this.setState({
selected: index,
hover: true,
});
},
onMouseLeave: function() {
@ -124,8 +124,8 @@ module.exports = React.createClass({
// Saving the addressListElement so we can use it to work out, in the componentDidUpdate
// method, how far to scroll when using the arrow keys
addressList.push(
<div className={classes} onClick={this.onClick(i)} onMouseEnter={this.onMouseEnter(i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.addressListElement = ref; }} >
<AddressTile address={this.props.addressList[i].userId} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
<div className={classes} onClick={this.onClick.bind(this, i)} onMouseEnter={this.onMouseEnter.bind(this, i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.addressListElement = ref; }} >
<AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
</div>
);
}
@ -135,7 +135,7 @@ module.exports = React.createClass({
_maxSelected: function(list) {
var listSize = list.length === 0 ? 0 : list.length - 1;
var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize
var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize;
return maxSelected;
},
@ -146,7 +146,8 @@ module.exports = React.createClass({
});
return (
<div className={classes} ref={(ref) => {this.scrollElement = ref}}>
<div className={classes} ref={(ref) => {this.scrollElement = ref;}}>
{ this.props.header }
{ this.createAddressListTiles() }
</div>
);

View file

@ -23,16 +23,33 @@ var Invite = require("../../../Invite");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Avatar = require('../../../Avatar');
module.exports = React.createClass({
// React PropType definition for an object describing
// an address that can be invited to a room (which
// could be a third party identifier or a matrix ID)
// along with some additional information about the
// address / target.
export const InviteAddressType = React.PropTypes.shape({
addressType: React.PropTypes.oneOf([
'mx', 'email'
]).isRequired,
address: React.PropTypes.string.isRequired,
displayName: React.PropTypes.string,
avatarMxc: React.PropTypes.string,
// true if the address is known to be a valid address (eg. is a real
// user we've seen) or false otherwise (eg. is just an address the
// user has entered)
isKnown: React.PropTypes.bool,
});
export default React.createClass({
displayName: 'AddressTile',
propTypes: {
address: React.PropTypes.string.isRequired,
address: InviteAddressType.isRequired,
canDismiss: React.PropTypes.bool,
onDismissed: React.PropTypes.func,
justified: React.PropTypes.bool,
networkName: React.PropTypes.string,
networkUrl: React.PropTypes.string,
},
getDefaultProps: function() {
@ -40,37 +57,30 @@ module.exports = React.createClass({
canDismiss: false,
onDismissed: function() {}, // NOP
justified: false,
networkName: "",
networkUrl: "",
};
},
render: function() {
var userId, name, imgUrl, email;
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const address = this.props.address;
const name = address.displayName || address.address;
// Check if the addr is a valid type
var addrType = Invite.getAddressType(this.props.address);
if (addrType === "mx") {
let user = MatrixClientPeg.get().getUser(this.props.address);
if (user) {
userId = user.userId;
name = user.rawDisplayName || userId;
imgUrl = Avatar.avatarUrlForUser(user, 25, 25, "crop");
} else {
name=this.props.address;
imgUrl = "img/icon-mx-user.svg";
}
} else if (addrType === "email") {
email = this.props.address;
name="email";
imgUrl = "img/icon-email-user.svg";
} else {
name="Unknown";
imgUrl = "img/avatar-error.svg";
let imgUrl;
if (address.avatarMxc) {
imgUrl = MatrixClientPeg.get().mxcUrlToHttp(
address.avatarMxc, 25, 25, 'crop'
);
}
if (address.addressType === "mx") {
if (!imgUrl) imgUrl = 'img/icon-mx-user.svg';
} else if (address.addressType === 'email') {
if (!imgUrl) imgUrl = 'img/icon-email-user.svg';
} else {
if (!imgUrl) imgUrl = "img/avatar-error.svg";
}
// Removing networks for now as they're not really supported
/*
var network;
if (this.props.networkUrl !== "") {
network = (
@ -79,16 +89,20 @@ module.exports = React.createClass({
</div>
);
}
*/
var info;
var error = false;
if (addrType === "mx" && userId) {
var nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
var idClasses = classNames({
const nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
let info;
let error = false;
if (address.addressType === "mx" && address.isKnown) {
const idClasses = classNames({
"mx_AddressTile_id": true,
"mx_AddressTile_justified": this.props.justified,
});
@ -96,26 +110,34 @@ module.exports = React.createClass({
info = (
<div className="mx_AddressTile_mx">
<div className={nameClasses}>{ name }</div>
<div className={idClasses}>{ userId }</div>
<div className={idClasses}>{ address.address }</div>
</div>
);
} else if (addrType === "mx") {
var unknownMxClasses = classNames({
} else if (address.addressType === "mx") {
const unknownMxClasses = classNames({
"mx_AddressTile_unknownMx": true,
"mx_AddressTile_justified": this.props.justified,
});
info = (
<div className={unknownMxClasses}>{ this.props.address }</div>
<div className={unknownMxClasses}>{ this.props.address.address }</div>
);
} else if (email) {
var emailClasses = classNames({
} else if (address.addressType === "email") {
const emailClasses = classNames({
"mx_AddressTile_email": true,
"mx_AddressTile_justified": this.props.justified,
});
let nameNode = null;
if (address.displayName) {
nameNode = <div className={nameClasses}>{ address.displayName }</div>
}
info = (
<div className={emailClasses}>{ email }</div>
<div className="mx_AddressTile_mx">
<div className={emailClasses}>{ address.address }</div>
{nameNode}
</div>
);
} else {
error = true;
@ -129,12 +151,12 @@ module.exports = React.createClass({
);
}
var classes = classNames({
const classes = classNames({
"mx_AddressTile": true,
"mx_AddressTile_error": error,
});
var dismiss;
let dismiss;
if (this.props.canDismiss) {
dismiss = (
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
@ -145,7 +167,6 @@ module.exports = React.createClass({
return (
<div className={classes}>
{ network }
<div className="mx_AddressTile_avatar">
<BaseAvatar width={25} height={25} name={name} title={name} url={imgUrl} />
</div>

View file

@ -42,14 +42,14 @@ export default React.createClass({
<div className="mx_UserSettings_cryptoSection">
<ul>
<li><label>Device name:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>Device ID:</label> <span><code>{ this.props.device.deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{ this.props.device.getFingerprint() }</b></code></span></li>
<li><label>Device ID:</label> <span><code>{ this.props.device.deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{ this.props.device.getFingerprint() }</b></code></span></li>
</ul>
</div>
<p>
If it matches, press the verify button below.
If it doesnt, then someone else is intercepting this device
and you probably want to press the block button instead.
and you probably want to press the blacklist button instead.
</p>
<p>
In future this verification process will be more sophisticated.
@ -73,33 +73,33 @@ export default React.createClass({
);
},
onBlockClick: function() {
onBlacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, true
);
},
onUnblockClick: function() {
onUnblacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, false
);
},
render: function() {
var blockButton = null, verifyButton = null;
var blacklistButton = null, verifyButton = null;
if (this.props.device.isBlocked()) {
blockButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblock"
onClick={this.onUnblockClick}>
Unblock
blacklistButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblacklist"
onClick={this.onUnblacklistClick}>
Unblacklist
</button>
);
} else {
blockButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_block"
onClick={this.onBlockClick}>
Block
blacklistButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_blacklist"
onClick={this.onBlacklistClick}>
Blacklist
</button>
);
}
@ -115,7 +115,7 @@ export default React.createClass({
verifyButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify"
onClick={this.onVerifyClick}>
Verify
Verify...
</button>
);
}
@ -124,7 +124,7 @@ export default React.createClass({
return (
<div className="mx_MemberDeviceInfo mx_DeviceVerifyButtons" >
{ verifyButton }
{ blockButton }
{ blacklistButton }
</div>
);
},

View file

@ -89,7 +89,7 @@ export default class DirectorySearchBox extends React.Component {
return <span className={classnames(searchbox_classes)}>
<div className="mx_DirectorySearchBox_container">
<input type="text" value={this.state.value}
<input type="text" name="dirsearch" value={this.state.value}
className="mx_DirectorySearchBox_input"
ref={this._collectInput}
onChange={this._onChange} onKeyUp={this._onKeyUp}

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
phase: this.Phases.Display,
}
};
},
componentWillReceiveProps: function(nextProps) {
@ -164,7 +164,7 @@ module.exports = React.createClass({
this.setState({
phase: this.Phases.Edit,
})
});
},
onFocus: function(ev) {
@ -197,9 +197,9 @@ module.exports = React.createClass({
sel.removeAllRanges();
if (this.props.blurToCancel)
this.cancelEdit();
{this.cancelEdit();}
else
this.onFinish(ev);
{this.onFinish(ev);}
this.showPlaceholder(!this.value);
},

View file

@ -24,7 +24,7 @@ module.exports = React.createClass({
events: React.PropTypes.array.isRequired,
// An array of EventTiles to render when expanded
children: React.PropTypes.array.isRequired,
// The maximum number of names to show in either the join or leave summaries
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength: React.PropTypes.number,
// The maximum number of avatars to display in the summary
avatarsMaxLength: React.PropTypes.number,
@ -40,93 +40,229 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
summaryLength: 3,
summaryLength: 1,
threshold: 3,
avatarsMaxLength: 5
avatarsMaxLength: 5,
};
},
shouldComponentUpdate: function(nextProps, nextState) {
// Update if
// - The number of summarised events has changed
// - or if the summary is currently expanded
// - or if the summary is about to toggle to become collapsed
// - or if there are fewEvents, meaning the child eventTiles are shown as-is
return (
nextProps.events.length !== this.props.events.length ||
this.state.expanded || nextState.expanded ||
nextProps.events.length < this.props.threshold
);
},
_toggleSummary: function() {
this.setState({
expanded: !this.state.expanded,
});
},
_getEventSenderName: function(ev) {
if (!ev) {
return 'undefined';
}
return ev.sender.name || ev.event.content.displayname || ev.getSender();
},
/**
* Render the JSX for users aggregated by their transition sequences (`eventAggregates`) where
* the sequences are ordered by `orderedTransitionSequences`.
* @param {object[]} eventAggregates a map of transition sequence to array of user display names
* or user IDs.
* @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`.
* @returns {ReactElement} a single <span> containing the textual summary of the aggregated
* events that occurred.
*/
_renderSummary: function(eventAggregates, orderedTransitionSequences) {
const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions];
const nameList = this._renderNameList(userNames);
const plural = userNames.length > 1;
_renderNameList: function(events) {
if (events.length === 0) {
const splitTransitions = transitions.split(',');
// Some neighbouring transitions are common, so canonicalise some into "pair"
// transitions
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's)
const coalescedTransitions = this._coalesceRepeatedTransitions(
canonicalTransitions
);
const descs = coalescedTransitions.map((t) => {
return this._getDescriptionForTransition(
t.transitionType, plural, t.repeats
);
});
const desc = this._renderCommaSeparatedList(descs);
return nameList + " " + desc;
});
if (!summaries) {
return null;
}
let originalNumber = events.length;
events = events.slice(0, this.props.summaryLength);
let lastEvent = events.pop();
let names = events.map((ev) => {
return this._getEventSenderName(ev);
}).join(', ');
let lastName = this._getEventSenderName(lastEvent);
if (names.length === 0) {
// special-case for a single event
return lastName;
}
let remaining = originalNumber - this.props.summaryLength;
if (remaining > 0) {
// name1, name2, name3, and 100 others
return names + ', ' + lastName + ', and ' + remaining + ' others';
} else {
// name1, name2 and name3
return names + ' and ' + lastName;
}
},
_renderSummary: function(joinEvents, leaveEvents) {
let joiners = this._renderNameList(joinEvents);
let leavers = this._renderNameList(leaveEvents);
let joinSummary = null;
if (joiners) {
joinSummary = (
<span>
{joiners} joined the room
</span>
);
}
let leaveSummary = null;
if (leavers) {
leaveSummary = (
<span>
{leavers} left the room
</span>
);
}
return (
<span>
{joinSummary}{joinSummary && leaveSummary?'; ':''}
{leaveSummary}
{summaries.join(", ")}
</span>
);
},
_renderAvatars: function(events) {
let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => {
/**
* @param {string[]} users an array of user display names or user IDs.
* @returns {string} a comma-separated list that ends with "and [n] others" if there are
* more items in `users` than `this.props.summaryLength`, which is the number of names
* included before "and [n] others".
*/
_renderNameList: function(users) {
return this._renderCommaSeparatedList(users, this.props.summaryLength);
},
/**
* Canonicalise an array of transitions such that some pairs of transitions become
* single transitions. For example an input ['joined','left'] would result in an output
* ['joined_and_left'].
* @param {string[]} transitions an array of transitions.
* @returns {string[]} an array of transitions.
*/
_getCanonicalTransitions: function(transitions) {
const modMap = {
'joined': {
'after': 'left',
'newTransition': 'joined_and_left',
},
'left': {
'after': 'joined',
'newTransition': 'left_and_joined',
},
// $currentTransition : {
// 'after' : $nextTransition,
// 'newTransition' : 'new_transition_type',
// },
};
const res = [];
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const t2 = transitions[i + 1];
let transition = t;
if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) {
transition = modMap[t].newTransition;
i++;
}
res.push(transition);
}
return res;
},
/**
* Transform an array of transitions into an array of transitions and how many times
* they are repeated consecutively.
*
* An array of 123 "joined_and_left" transitions, would result in:
* ```
* [{
* transitionType: "joined_and_left"
* repeats: 123
* }]
* ```
* @param {string[]} transitions the array of transitions to transform.
* @returns {object[]} an array of coalesced transitions.
*/
_coalesceRepeatedTransitions: function(transitions) {
const res = [];
for (let i = 0; i < transitions.length; i++) {
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
res[res.length - 1].repeats += 1;
} else {
res.push({
transitionType: transitions[i],
repeats: 1,
});
}
}
return res;
},
/**
* For a certain transition, t, describe what happened to the users that
* underwent the transition.
* @param {string} t the transition type.
* @param {boolean} plural whether there were multiple users undergoing the same
* transition.
* @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written English equivalent of the transition.
*/
_getDescriptionForTransition(t, plural, repeats) {
const beConjugated = plural ? "were" : "was";
const invitation = "their invitation" + (plural || (repeats > 1) ? "s" : "");
let res = null;
const map = {
"joined": "joined",
"left": "left",
"joined_and_left": "joined and left",
"left_and_joined": "left and rejoined",
"invite_reject": "rejected " + invitation,
"invite_withdrawal": "had " + invitation + " withdrawn",
"invited": beConjugated + " invited",
"banned": beConjugated + " banned",
"unbanned": beConjugated + " unbanned",
"kicked": beConjugated + " kicked",
};
if (Object.keys(map).includes(t)) {
res = map[t] + (repeats > 1 ? " " + repeats + " times" : "" );
}
return res;
},
/**
* Constructs a written English string representing `items`, with an optional limit on
* the number of items included in the result. If specified and if the length of
*`items` is greater than the limit, the string "and n others" will be appended onto
* the result.
* If `items` is empty, returns the empty string. If there is only one item, return
* it.
* @param {string[]} items the items to construct a string from.
* @param {number?} itemLimit the number by which to limit the list.
* @returns {string} a string constructed by joining `items` with a comma between each
* item, but with the last item appended as " and [lastItem]".
*/
_renderCommaSeparatedList(items, itemLimit) {
const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0
);
if (items.length === 0) {
return "";
} else if (items.length === 1) {
return items[0];
} else if (remaining) {
items = items.slice(0, itemLimit);
const other = " other" + (remaining > 1 ? "s" : "");
return items.join(', ') + ' and ' + remaining + other;
} else {
const lastItem = items.pop();
return items.join(', ') + ' and ' + lastItem;
}
},
_renderAvatars: function(roomMembers) {
const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => {
return (
<MemberAvatar
key={e.getId()}
member={e.sender}
width={14}
height={14}
/>
<MemberAvatar key={m.userId} member={m} width={14} height={14} />
);
});
return (
<span>
{avatars}
@ -134,74 +270,155 @@ module.exports = React.createClass({
);
},
render: function() {
let summary = null;
_getTransitionSequence: function(events) {
return events.map(this._getTransition);
},
// Reorder events so that joins come before leaves
let eventsToRender = this.props.events;
// Filter out those who joined, then left
let filteredEvents = eventsToRender.filter(
(e) => {
return eventsToRender.filter(
(e2) => {
return e.getSender() === e2.getSender()
&& e.event.content.membership !== e2.event.content.membership;
/**
* Label a given membership event, `e`, where `getContent().membership` has
* changed for each transition allowed by the Matrix protocol. This attempts to
* label the membership changes that occur in `../../../TextForEvent.js`.
* @param {MatrixEvent} e the membership change event to label.
* @returns {string?} the transition type given to this event. This defaults to `null`
* if a transition is not recognised.
*/
_getTransition: function(e) {
switch (e.mxEvent.getContent().membership) {
case 'invite': return 'invited';
case 'ban': return 'banned';
case 'join': return 'joined';
case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_reject';
default: return 'left';
}
).length === 0;
}
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return 'invite_withdrawal';
case 'ban': return 'unbanned';
case 'join': return 'kicked';
default: return 'left';
}
default: return null;
}
},
_getAggregate: function(userEvents) {
// A map of aggregate type to arrays of display names. Each aggregate type
// is a comma-delimited string of transitions, e.g. "joined,left,kicked".
// The array of display names is the array of users who went through that
// sequence during eventsToRender.
const aggregate = {
// $aggregateType : []:string
};
// A map of aggregate types to the indices that order them (the index of
// the first event for a given transition sequence)
const aggregateIndices = {
// $aggregateType : int
};
const users = Object.keys(userEvents);
users.forEach(
(userId) => {
const firstEvent = userEvents[userId][0];
const displayName = firstEvent.displayName;
const seq = this._getTransitionSequence(userEvents[userId]);
if (!aggregate[seq]) {
aggregate[seq] = [];
aggregateIndices[seq] = -1;
}
aggregate[seq].push(displayName);
if (aggregateIndices[seq] === -1 ||
firstEvent.index < aggregateIndices[seq]) {
aggregateIndices[seq] = firstEvent.index;
}
}
);
let joinAndLeft = (eventsToRender.length - filteredEvents.length) / 2;
if (joinAndLeft <= 0 || joinAndLeft % 1 !== 0) {
joinAndLeft = null;
}
return {
names: aggregate,
indices: aggregateIndices,
};
},
let joinEvents = filteredEvents.filter((ev) => {
return ev.event.content.membership === 'join';
});
render: function() {
const eventsToRender = this.props.events;
const fewEvents = eventsToRender.length < this.props.threshold;
const expanded = this.state.expanded || fewEvents;
let leaveEvents = filteredEvents.filter((ev) => {
return ev.event.content.membership === 'leave';
});
let fewEvents = eventsToRender.length < this.props.threshold;
let expanded = this.state.expanded || fewEvents;
let expandedEvents = null;
if (expanded) {
expandedEvents = this.props.children;
}
let avatars = this._renderAvatars(joinEvents.concat(leaveEvents));
let toggleButton = null;
let summaryContainer = null;
if (!fewEvents) {
summary = this._renderSummary(joinEvents, leaveEvents);
toggleButton = (
<a className="mx_MemberEventListSummary_toggle" onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</a>
);
let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users';
let noun = (joinAndLeft === 1 ? 'user' : plural);
summaryContainer = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_MemberEventListSummary_avatars">
{avatars}
</span>
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summary}{joinAndLeft? '. ' + joinAndLeft + ' ' + noun + ' joined and left' : ''}
</span>&nbsp;
{toggleButton}
</div>
if (fewEvents) {
return (
<div className="mx_MemberEventListSummary">
{expandedEvents}
</div>
);
}
// Map user IDs to an array of objects:
const userEvents = {
// $userId : [{
// // The original event
// mxEvent: e,
// // The display name of the user (if not, then user ID)
// displayName: e.target.name || userId,
// // The original index of the event in this.props.events
// index: index,
// }]
};
const avatarMembers = [];
eventsToRender.forEach((e, index) => {
const userId = e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
userEvents[userId] = [];
avatarMembers.push(e.target);
}
userEvents[userId].push({
mxEvent: e,
displayName: e.target.name || userId,
index: index,
});
});
const aggregate = this._getAggregate(userEvents);
// Sort types by order of lowest event index within sequence
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
(seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2]
);
const avatars = this._renderAvatars(avatarMembers);
const summary = this._renderSummary(aggregate.names, orderedTransitionSequences);
const toggleButton = (
<a className="mx_MemberEventListSummary_toggle" onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</a>
);
const summaryContainer = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_MemberEventListSummary_avatars">
{avatars}
</span>
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summary}
</span>&nbsp;
{toggleButton}
</div>
</div>
);
return (
<div className="mx_MemberEventListSummary">
{summaryContainer}

View file

@ -73,7 +73,7 @@ module.exports = React.createClass({
getValue: function() {
var value;
if (this.refs.select) {
value = reverseRoles[ this.refs.select.value ];
value = reverseRoles[this.refs.select.value];
if (this.refs.custom) {
if (value === undefined) value = parseInt( this.refs.custom.value );
}
@ -86,10 +86,10 @@ module.exports = React.createClass({
if (this.state.custom) {
var input;
if (this.props.disabled) {
input = <span>{ this.props.value }</span>
input = <span>{ this.props.value }</span>;
}
else {
input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/>
input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/>;
}
customPicker = <span> of { input }</span>;
}
@ -115,7 +115,7 @@ module.exports = React.createClass({
<option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option>
</select>
</select>;
}
return (

View file

@ -35,4 +35,4 @@ module.exports = React.createClass({
<div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div>
);
}
});
});

View file

@ -69,9 +69,19 @@ var TintableSvg = React.createClass({
width={ this.props.width }
height={ this.props.height }
onLoad={ this.onLoad }
tabIndex="-1"
/>
);
}
});
// Register with the Tinter so that we will be told if the tint changes
Tinter.registerTintable(function() {
if (TintableSvg.mounts) {
Object.keys(TintableSvg.mounts).forEach((id) => {
TintableSvg.mounts[id].tint();
});
}
});
module.exports = TintableSvg;

View file

@ -55,7 +55,7 @@ module.exports = React.createClass({
overflowJsx = this.props.createOverflowElement(
overflowCount, childCount
);
// cut out the overflow elements
childArray.splice(childCount - overflowCount, overflowCount);
childsJsx = childArray; // use what is left

View file

@ -56,7 +56,7 @@ module.exports = React.createClass({
<div>
<ul className="mx_UserSelector_UserIdList" ref="list">
{this.props.selected_users.map(function(user_id, i) {
return <li key={user_id}>{user_id} - <span onClick={function() {self.removeUser(user_id);}}>X</span></li>
return <li key={user_id}>{user_id} - <span onClick={function() {self.removeUser(user_id);}}>X</span></li>;
})}
</ul>
<input type="text" ref="user_id_input" defaultValue="" className="mx_UserSelector_userIdInput" placeholder="ex. @bob:example.com"/>

View file

@ -52,12 +52,24 @@ module.exports = React.createClass({
this._onCaptchaLoaded();
} else {
console.log("Loading recaptcha script...");
var scriptTag = document.createElement('script');
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()};
scriptTag.setAttribute(
'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"
);
this.refs.recaptchaContainer.appendChild(scriptTag);
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
var protocol = global.location.protocol;
if (protocol === "file:") {
var warning = document.createElement('div');
// XXX: fix hardcoded app URL. Better solutions include:
// * jumping straight to a hosted captcha page (but we don't support that yet)
// * embedding the captcha in an iframe (if that works)
// * using a better captcha lib
warning.innerHTML = "Robot check is currently unavailable on desktop - please use a <a href='https://riot.im/app'>web browser</a>.";
this.refs.recaptchaContainer.appendChild(warning);
}
else {
var scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"
);
this.refs.recaptchaContainer.appendChild(scriptTag);
}
}
},
@ -89,7 +101,7 @@ module.exports = React.createClass({
} catch (e) {
this.setState({
errorText: e.toString(),
})
});
}
},
@ -106,6 +118,7 @@ module.exports = React.createClass({
return (
<div ref="recaptchaContainer">
This Home Server would like to make sure you are not a robot
<br/>
<div id={DIV_ID}></div>
{error}
</div>

View file

@ -70,7 +70,7 @@ export const PasswordAuthEntry = React.createClass({
});
},
_onPasswordFieldChange: function (ev) {
_onPasswordFieldChange: function(ev) {
// enable the submit button iff the password is non-empty
this.props.setSubmitButtonEnabled(Boolean(ev.target.value));
},
@ -209,4 +209,4 @@ export function getEntryComponentForLoginType(loginType) {
}
}
return FallbackAuthEntry;
};
}

View file

@ -38,6 +38,16 @@ module.exports = React.createClass({
defaultEmail: React.PropTypes.string,
defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({
// Email address to request new teams
supportEmail: React.PropTypes.string,
teams: React.PropTypes.arrayOf(React.PropTypes.shape({
// The displayed name of the team
"name": React.PropTypes.string,
// The domain of team email addresses
"domain": React.PropTypes.string,
})).required,
}),
// A username that will be used if no username is entered.
// Specifying this param will also warn the user that entering
@ -62,7 +72,8 @@ module.exports = React.createClass({
getInitialState: function() {
return {
fieldValid: {}
fieldValid: {},
selectedTeam: null,
};
},
@ -105,10 +116,11 @@ module.exports = React.createClass({
},
_doSubmit: function() {
let email = this.refs.email.value.trim();
var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(),
email: this.refs.email.value.trim()
email: email,
});
if (promise) {
@ -133,17 +145,37 @@ module.exports = React.createClass({
return true;
},
_isUniEmail: function(email) {
return email.endsWith('.ac.uk') || email.endsWith('.edu');
},
validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim()
var pwd2 = this.refs.passwordConfirm.value.trim();
switch (field_id) {
case FIELD_EMAIL:
this.markFieldValid(
field_id,
this.refs.email.value == '' || Email.looksValid(this.refs.email.value),
"RegistrationForm.ERR_EMAIL_INVALID"
);
const email = this.refs.email.value;
if (this.props.teamsConfig && this._isUniEmail(email)) {
const matchingTeam = this.props.teamsConfig.teams.find(
(team) => {
return email.split('@').pop() === team.domain;
}
) || null;
this.setState({
selectedTeam: matchingTeam,
showSupportEmail: !matchingTeam,
});
this.props.onTeamSelected(matchingTeam);
} else {
this.props.onTeamSelected(null);
this.setState({
selectedTeam: null,
showSupportEmail: false,
});
}
const valid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
case FIELD_USERNAME:
// XXX: SPEC-1
@ -224,15 +256,36 @@ module.exports = React.createClass({
render: function() {
var self = this;
var emailSection, registerButton;
var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) {
emailSection = (
<input type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_EMAIL)}} />
onBlur={function() {self.validateField(FIELD_EMAIL);}}
value={self.state.email}/>
);
if (this.props.teamsConfig) {
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
belowEmailSection = (
<p className="mx_Login_support">
Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>&nbsp;
to get your university signed up. Or continue to register with Riot to enjoy our open source platform.
</p>
);
} else if (this.state.selectedTeam) {
belowEmailSection = (
<p className="mx_Login_support">
You are registering with {this.state.selectedTeam.name}
</p>
);
}
}
}
if (this.props.onRegisterClick) {
registerButton = (
@ -242,31 +295,31 @@ module.exports = React.createClass({
var placeholderUserName = "User name";
if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.guestUsername + ")"
placeholderUserName += " (default: " + this.props.guestUsername + ")";
}
return (
<div>
<form onSubmit={this.onSubmit}>
{emailSection}
<br />
{belowEmailSection}
<input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_USERNAME)}} />
onBlur={function() {self.validateField(FIELD_USERNAME);}} />
<br />
{ this.props.guestUsername ?
<div className="mx_Login_fieldLabel">Setting a user name will create a fresh account</div> : null
}
<input type="password" ref="password"
className={this._classForField(FIELD_PASSWORD, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_PASSWORD)}}
onBlur={function() {self.validateField(FIELD_PASSWORD);}}
placeholder="Password" defaultValue={this.props.defaultPassword} />
<br />
<input type="password" ref="passwordConfirm"
placeholder="Confirm password"
className={this._classForField(FIELD_PASSWORD_CONFIRM, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM);}}
defaultValue={this.props.defaultPassword} />
<br />
{registerButton}

View file

@ -64,10 +64,10 @@ module.exports = React.createClass({
hs_url: this.props.customHsUrl,
is_url: this.props.customIsUrl,
// if withToggleButton is false, then show the config all the time given we have no way otherwise of making it visible
configVisible: !this.props.withToggleButton ||
configVisible: !this.props.withToggleButton ||
(this.props.customHsUrl !== this.props.defaultHsUrl) ||
(this.props.customIsUrl !== this.props.defaultIsUrl)
}
};
},
onHomeserverChanged: function(ev) {

View file

@ -21,7 +21,7 @@ import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
export default class MAudioBody extends React.Component {
constructor(props) {
@ -29,7 +29,9 @@ export default class MAudioBody extends React.Component {
this.state = {
playing: false,
decryptedUrl: null,
}
decryptedBlob: null,
error: null,
};
}
onPlayToggle() {
this.setState({
@ -49,29 +51,45 @@ export default class MAudioBody extends React.Component {
componentDidMount() {
var content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
var decryptedBlob;
decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(decryptedBlob);
}).done((url) => {
this.setState({
decryptedUrl: url
decryptedUrl: url,
decryptedBlob: decryptedBlob,
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
console.warn("Unable to decrypt attachment: ", err);
this.setState({
error: err,
});
});
}
}
render() {
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MAudioBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting audio
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
// For now add an img tag with a 16x16 spinner.
// Not sure how tall the audio player is so not sure how tall it should actually be.
return (
<span className="mx_MAudioBody">
<img src="img/spinner.gif" ref="image"
alt={content.body} />
<img src="img/spinner.gif" alt={content.body} width="16" height="16"/>
</span>
);
}
@ -81,7 +99,7 @@ export default class MAudioBody extends React.Component {
return (
<span className="mx_MAudioBody">
<audio src={contentUrl} controls />
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
}

View file

@ -21,120 +21,332 @@ import filesize from 'filesize';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import {decryptFile} from '../../../utils/DecryptFile';
import Tinter from '../../../Tinter';
import request from 'browser-request';
import q from 'q';
import Modal from '../../../Modal';
// A cached tinted copy of "img/download.svg"
var tintedDownloadImageURL;
// Track a list of mounted MFileBody instances so that we can update
// the "img/download.svg" when the tint changes.
var nextMountId = 0;
const mounts = {};
/**
* Updates the tinted copy of "img/download.svg" when the tint changes.
*/
function updateTintedDownloadImage() {
// Download the svg as an XML document.
// We could cache the XML response here, but since the tint rarely changes
// it's probably not worth it.
// Also note that we can't use fetch here because fetch doesn't support
// file URLs, which the download image will be if we're running from
// the filesystem (like in an Electron wrapper).
request({uri: "img/download.svg"}, (err, response, body) => {
if (err) return;
const svg = new DOMParser().parseFromString(body, "image/svg+xml");
// Apply the fixups to the XML.
const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]);
Tinter.applySvgFixups(fixups);
// Encoded the fixed up SVG as a data URL.
const svgString = new XMLSerializer().serializeToString(svg);
tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString);
// Notify each mounted MFileBody that the URL has changed.
Object.keys(mounts).forEach(function(id) {
mounts[id].tint();
});
});
}
Tinter.registerTintable(updateTintedDownloadImage);
// User supplied content can contain scripts, we have to be careful that
// we don't accidentally run those script within the same origin as the
// client. Otherwise those scripts written by remote users can read
// the access token and end-to-end keys that are in local storage.
//
// For attachments downloaded directly from the homeserver we can use
// Content-Security-Policy headers to disable script execution.
//
// But attachments with end-to-end encryption are more difficult to handle.
// We need to decrypt the attachment on the client and then display it.
// To display the attachment we need to turn the decrypted bytes into a URL.
//
// There are two ways to turn bytes into URLs, data URL and blob URLs.
// Data URLs aren't suitable for downloading a file because Chrome has a
// 2MB limit on the size of URLs that can be viewed in the browser or
// downloaded. This limit does not seem to apply when the url is used as
// the source attribute of an image tag.
//
// Blob URLs are generated using window.URL.createObjectURL and unforuntately
// for our purposes they inherit the origin of the page that created them.
// This means that any scripts that run when the URL is viewed will be able
// to access local storage.
//
// The easiest solution is to host the code that generates the blob URL on
// a different domain to the client.
// Another possibility is to generate the blob URL within a sandboxed iframe.
// The downside of using a second domain is that it complicates hosting,
// the downside of using a sandboxed iframe is that the browers are overly
// restrictive in what you are allowed to do with the generated URL.
//
// For now given how unusable the blobs generated in sandboxed iframes are we
// default to using a renderer hosted on "usercontent.riot.im". This is
// overridable so that people running their own version of the client can
// choose a different renderer.
//
// To that end the first version of the blob generation will be the following
// html:
//
// <html><head><script>
// window.onmessage=function(e){eval("("+e.data.code+")")(e)}
// </script></head><body></body></html>
//
// This waits to receive a message event sent using the window.postMessage API.
// When it receives the event it evals a javascript function in data.code and
// runs the function passing the event as an argument.
//
// In particular it means that the rendering function can be written as a
// ordinary javascript function which then is turned into a string using
// toString().
//
const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html";
/**
* Render the attachment inside the iframe.
* We can't use imported libraries here so this has to be vanilla JS.
*/
function remoteRender(event) {
const data = event.data;
const img = document.createElement("img");
img.id = "img";
img.src = data.imgSrc;
const a = document.createElement("a");
a.id = "a";
a.rel = data.rel;
a.target = data.target;
a.download = data.download;
a.style = data.style;
a.href = window.URL.createObjectURL(data.blob);
a.appendChild(img);
a.appendChild(document.createTextNode(data.textContent));
const body = document.body;
// Don't display scrollbars if the link takes more than one line
// to display.
body.style = "margin: 0px; overflow: hidden";
body.appendChild(a);
}
/**
* Update the tint inside the iframe.
* We can't use imported libraries here so this has to be vanilla JS.
*/
function remoteSetTint(event) {
const data = event.data;
const img = document.getElementById("img");
img.src = data.imgSrc;
img.style = data.imgStyle;
const a = document.getElementById("a");
a.style = data.style;
}
/**
* Get the current CSS style for a DOMElement.
* @param {HTMLElement} element The element to get the current style of.
* @return {string} The CSS style encoded as a string.
*/
function computedStyle(element) {
if (!element) {
return "";
}
const style = window.getComputedStyle(element, null);
var cssText = style.cssText;
if (cssText == "") {
// Firefox doesn't implement ".cssText" for computed styles.
// https://bugzilla.mozilla.org/show_bug.cgi?id=137687
for (var i = 0; i < style.length; i++) {
cssText += style[i] + ":";
cssText += style.getPropertyValue(style[i]) + ";";
}
}
return cssText;
}
module.exports = React.createClass({
displayName: 'MFileBody',
getInitialState: function() {
return {
decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null),
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
},
contextTypes: {
appConfig: React.PropTypes.object,
},
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @params {Object} content The "content" key of the matrix event.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile: function(content) {
var linkText = 'Attachment';
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
var additionals = [];
if (content.info) {
// if (content.info.mimetype && content.info.mimetype.length > 0) {
// additionals.push(content.info.mimetype);
// }
if (content.info.size) {
additionals.push(filesize(content.info.size));
}
}
if (additionals.length > 0) {
linkText += ' (' + additionals.join(', ') + ')';
if (content.info && content.info.size) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
},
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
},
componentDidMount: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
this.setState({
decryptedUrl: url,
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
});
// Add this to the list of mounted components to receive notifications
// when the tint changes.
this.id = nextMountId++;
mounts[this.id] = this;
this.tint();
},
componentWillUnmount: function() {
// Remove this from the list of mounted components
delete mounts[this.id];
},
tint: function() {
// Update our tinted copy of "img/download.svg"
if (this.refs.downloadImage) {
this.refs.downloadImage.src = tintedDownloadImageURL;
}
if (this.refs.iframe) {
// If the attachment is encrypted then the download image
// will be inside the iframe so we wont be able to update
// it directly.
this.refs.iframe.contentWindow.postMessage({
code: remoteSetTint.toString(),
imgSrc: tintedDownloadImageURL,
style: computedStyle(this.refs.dummyLink),
}, "*");
}
},
render: function() {
const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content);
const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : "Attachment";
const contentUrl = this._getContentUrl();
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
if (content.file !== undefined && this.state.decryptedUrl === null) {
if (isEncrypted) {
if (this.state.decryptedBlob === null) {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
var decrypting = false;
const decrypt = () => {
if (decrypting) {
return false;
}
decrypting = true;
decryptFile(content.file).then((blob) => {
this.setState({
decryptedBlob: blob,
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err);
Modal.createDialog(ErrorDialog, {
description: "Error decrypting attachment"
});
}).finally(() => {
decrypting = false;
return;
});
};
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MFileBody" ref="body">
<div className="mx_MImageBody_download">
<a href="javascript:void(0)" onClick={decrypt}>
Decrypt {text}
</a>
</div>
</span>
);
}
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
code: remoteRender.toString(),
imgSrc: tintedDownloadImageURL,
style: computedStyle(this.refs.dummyLink),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
target: "_blank",
textContent: "Download " + text,
}, "*");
};
// If the attachment is encryped then put the link inside an iframe.
let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER;
if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) {
renderer_url = this.context.appConfig.cross_origin_renderer_url;
}
return (
<span className="mx_MFileBody" ref="body">
<img src="img/spinner.gif" ref="image"
alt={content.body} />
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<div style={{display: "none"}}>
{/*
* Add dummy copy of the "a" tag
* We'll use it to learn how the download link
* would have been styled if it was rendered inline.
*/}
<a ref="dummyLink"/>
</div>
<iframe src={renderer_url} onLoad={onIframeLoad} ref="iframe"/>
</div>
</span>
);
}
const contentUrl = this._getContentUrl();
const fileName = content.body && content.body.length > 0 ? content.body : "Attachment";
var downloadAttr = undefined;
if (this.state.decryptedUrl) {
// If the file is encrypted then we MUST download it rather than displaying it
// because Firefox is vunerable to XSS attacks in data:// URLs
// and all browsers are vunerable to XSS attacks in blob: URLs
// created with window.URL.createObjectURL
// See https://bugzilla.mozilla.org/show_bug.cgi?id=255107
// See https://w3c.github.io/FileAPI/#originOfBlobURL
//
// This is not a problem for unencrypted links because they are
// either fetched from a different domain so are safe because of
// the same-origin policy or they are fetch from the same domain,
// in which case we trust that the homeserver will set a
// Content-Security-Policy that disables script execution.
// It is reasonable to trust the homeserver in that case since
// it is the same domain that controls this javascript.
//
// We can't apply the same workaround for encrypted files because
// we can't supply HTTP headers when the user clicks on a blob:
// or data:// uri.
//
// We should probably provide a download attribute anyway so that
// the file will have the correct name when the user tries to
// download it. We can't provide a Content-Disposition header
// like we would for HTTP.
downloadAttr = fileName;
}
if (contentUrl) {
} else if (contentUrl) {
// If the attachment is not encrypted then we check whether we
// are being displayed in the room timeline or in a list of
// files in the right hand side of the screen.
if (this.props.tileShape === "file_grid") {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}>
<a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank">
{ fileName }
</a>
<div className="mx_MImageBody_size">
@ -148,8 +360,8 @@ module.exports = React.createClass({
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}>
<TintableSvg src="img/download.svg" width="12" height="14"/>
<a href={contentUrl} target="_blank" rel="noopener">
<img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage"/>
Download {text}
</a>
</div>
@ -160,7 +372,7 @@ module.exports = React.createClass({
var extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">
Invalid file{extra}
</span>
</span>;
}
},
});

View file

@ -23,7 +23,8 @@ import ImageUtils from '../../../ImageUtils';
import Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import {decryptFile} from '../../../utils/DecryptFile';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q';
module.exports = React.createClass({
displayName: 'MImageBody',
@ -31,11 +32,17 @@ module.exports = React.createClass({
propTypes: {
/* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired,
/* called when the image has loaded */
onWidgetLoad: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null
};
},
@ -94,7 +101,9 @@ module.exports = React.createClass({
_getThumbUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
// TODO: Decrypt and use the thumbnail file if one is present.
if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl;
}
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600);
@ -106,15 +115,34 @@ module.exports = React.createClass({
this.fixupHeight();
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => {
this.setState({
decryptedUrl: url,
var thumbnailPromise = q(null);
if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file
).then(function(blob) {
return readBlobAsDataUri(blob);
});
}, (err) => {
console.warn("Unable to decrypt attachment: ", err)
}
var decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
}).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onWidgetLoad();
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
});
this.setState({
error: err,
});
}).done();
}
},
@ -152,6 +180,15 @@ module.exports = React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MImageBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting image
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
@ -159,8 +196,15 @@ module.exports = React.createClass({
// For now add an img tag with a spinner.
return (
<span className="mx_MImageBody" ref="body">
<img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image"
alt={content.body} />
<div className="mx_MImageBody_thumbnail" ref="image" style={{
"display": "flex",
"alignItems": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
"margin": "auto",
}}/>
</div>
</span>
);
}
@ -177,7 +221,7 @@ module.exports = React.createClass({
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
} else if (content.body) {

View file

@ -21,16 +21,26 @@ import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Model from '../../../Modal';
import sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q';
module.exports = React.createClass({
displayName: 'MVideoBody',
propTypes: {
/* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired,
/* called when the video has loaded */
onWidgetLoad: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
};
},
@ -83,19 +93,29 @@ module.exports = React.createClass({
if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file
);
).then(function(blob) {
return readBlobAsDataUri(blob);
});
}
var decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
decryptFile(content.file).then((contentUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
}).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onWidgetLoad();
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err)
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg";
this.setState({
error: err,
});
}).done();
}
},
@ -103,14 +123,29 @@ module.exports = React.createClass({
render: function() {
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MVideoBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting video
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MImageBody" ref="body">
<img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image"
alt={content.body} />
<span className="mx_MVideoBody" ref="body">
<div className="mx_MImageBody_thumbnail" ref="image" style={{
"display": "flex",
"align-items": "center",
"justify-items": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="16" height="16"/>
</div>
</span>
);
}
@ -141,7 +176,7 @@ module.exports = React.createClass({
controls preload={preload} autoPlay={false}
height={height} width={width} poster={poster}>
</video>
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
},

View file

@ -27,7 +27,6 @@ var sdk = require('../../../index');
var ScalarAuthClient = require("../../../ScalarAuthClient");
var Modal = require("../../../Modal");
var SdkConfig = require('../../../SdkConfig');
var UserSettingsStore = require('../../../UserSettingsStore');
linkifyMatrix(linkify);
@ -201,7 +200,7 @@ module.exports = React.createClass({
global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId());
}
},
}
};
},
onStarterLinkClick: function(starterLink, ev) {
@ -213,16 +212,6 @@ module.exports = React.createClass({
// which requires the user to click through and THEN we can open the link in a new tab because
// the window.open command occurs in the same stack frame as the onClick callback.
let integrationsEnabled = UserSettingsStore.isFeatureEnabled("integration_management");
if (!integrationsEnabled) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Integrations disabled",
description: "You need to enable the Labs option 'Integrations Management' in your Riot user settings first.",
});
return;
}
// Go fetch a scalar token
let scalarClient = new ScalarAuthClient();
scalarClient.connect().then(() => {
@ -241,8 +230,8 @@ module.exports = React.createClass({
if (!confirmed) {
return;
}
let width = window.screen.width > 1024 ? 1024 : window.screen.width;
let height = window.screen.height > 800 ? 800 : window.screen.height;
let width = window.screen.width > 1024 ? 1024 : window.screen.width;
let height = window.screen.height > 800 ? 800 : window.screen.height;
let left = (window.screen.width - width) / 2;
let top = (window.screen.height - height) / 2;
window.open(completeUrl, '_blank', `height=${height}, width=${width}, top=${top}, left=${left},`);
@ -304,4 +293,3 @@ module.exports = React.createClass({
}
},
});

View file

@ -103,13 +103,13 @@ module.exports = React.createClass({
}
if (oldCanonicalAlias !== this.state.canonicalAlias) {
console.log("AliasSettings: Updating canonical alias");
promises = [ q.all(promises).then(
promises = [q.all(promises).then(
MatrixClientPeg.get().sendStateEvent(
this.props.roomId, "m.room.canonical_alias", {
alias: this.state.canonicalAlias
}, ""
)
) ];
)];
}
return promises;
@ -144,7 +144,7 @@ module.exports = React.createClass({
// XXX: do we need to deep copy aliases before editing it?
this.state.domainToAliases[domain] = this.state.domainToAliases[domain] || [];
this.state.domainToAliases[domain].push(alias);
this.setState({
this.setState({
domainToAliases: this.state.domainToAliases
});
@ -152,9 +152,9 @@ module.exports = React.createClass({
this.refs.add_alias.setValue(''); // FIXME
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid alias format",
title: "Invalid alias format",
description: "'" + alias + "' is not a valid format for an alias",
});
}
@ -168,9 +168,9 @@ module.exports = React.createClass({
this.state.domainToAliases[domain][index] = alias;
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid address format",
title: "Invalid address format",
description: "'" + alias + "' is not a valid format for an address",
});
}
@ -183,7 +183,7 @@ module.exports = React.createClass({
// would be to arbitrarily deepcopy to a temp variable and then setState
// that, but why bother when we can cut this corner.
var alias = this.state.domainToAliases[domain].splice(index, 1);
this.setState({
this.setState({
domainToAliases: this.state.domainToAliases
});
},
@ -281,7 +281,7 @@ module.exports = React.createClass({
onValueChanged={ self.onAliasChanged.bind(self, localDomain, i) }
editable={ self.props.canSetAliases }
initialValue={ alias } />
<div className="mx_RoomSettings_deleteAlias">
<div className="mx_RoomSettings_deleteAlias mx_filterFlipColor">
{ deleteButton }
</div>
</div>
@ -297,7 +297,7 @@ module.exports = React.createClass({
placeholder={ "New address (e.g. #foo:" + localDomain + ")" }
blurToCancel={ false }
onValueChanged={ self.onAliasAdded } />
<div className="mx_RoomSettings_addAlias">
<div className="mx_RoomSettings_addAlias mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt="Add"
onClick={ self.onAliasAdded.bind(self, undefined) }/>
</div>
@ -310,4 +310,4 @@ module.exports = React.createClass({
</div>
);
}
});
});

View file

@ -135,7 +135,7 @@ module.exports = React.createClass({
</div>
);
}
var boundClick = this._onColorSchemeChanged.bind(this, i)
var boundClick = this._onColorSchemeChanged.bind(this, i);
return (
<div className="mx_RoomSettings_roomColor"
key={ "room_color_" + i }

View file

@ -121,13 +121,13 @@ module.exports = React.createClass({
onChange={ this.onGlobalDisableUrlPreviewChange }
checked={ this.state.globalDisableUrlPreview } />
Disable URL previews by default for participants in this room
</label>
</label>;
}
else {
disableRoomPreviewUrls =
<label>
URL previews are { this.state.globalDisableUrlPreview ? "disabled" : "enabled" } by default for participants in this room.
</label>
</label>;
}
return (
@ -154,4 +154,4 @@ module.exports = React.createClass({
);
}
});
});

View file

@ -93,8 +93,8 @@ module.exports = React.createClass({
}
else {
joinText = (<span>
Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}}
href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }}
Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}}
href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video'); }}
href="#">video</a>.
</span>);

View file

@ -20,6 +20,7 @@ var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
import AccessibleButton from '../elements/AccessibleButton';
var PRESENCE_CLASS = {
@ -152,7 +153,7 @@ module.exports = React.createClass({
var av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
return (
<div className={mainClassName} title={ this.props.title }
<AccessibleButton className={mainClassName} title={ this.props.title }
onClick={ this.props.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }>
<div className="mx_EntityTile_avatar">
@ -161,7 +162,7 @@ module.exports = React.createClass({
</div>
{ nameEl }
{ inviteButton }
</div>
</AccessibleButton>
);
}
});

View file

@ -21,8 +21,8 @@ var classNames = require("classnames");
var Modal = require('../../../Modal');
var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg')
var TextForEvent = require('../../../TextForEvent');
import WithMatrixClient from '../../../wrappers/WithMatrixClient';
var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher");
@ -63,22 +63,13 @@ var MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
module.exports = React.createClass({
module.exports = WithMatrixClient(React.createClass({
displayName: 'EventTile',
statics: {
haveTileForEvent: function(e) {
if (e.isRedacted()) return false;
if (eventTileTypes[e.getType()] == undefined) return false;
if (eventTileTypes[e.getType()] == 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== '';
} else {
return true;
}
}
},
propTypes: {
/* MatrixClient instance for sender verification etc */
matrixClient: React.PropTypes.object.isRequired,
/* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired,
@ -112,7 +103,7 @@ module.exports = React.createClass({
/* callback called when dynamic content in events are loaded */
onWidgetLoad: React.PropTypes.func,
/* a list of Room Members whose read-receipts we should show */
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */
readReceipts: React.PropTypes.arrayOf(React.PropTypes.object),
/* opaque readreceipt info for each userId; used by ReadReceiptMarker
@ -153,17 +144,18 @@ module.exports = React.createClass({
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
MatrixClientPeg.get().on("deviceVerificationChanged",
this.props.matrixClient.on("deviceVerificationChanged",
this.onDeviceVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
},
componentWillReceiveProps: function (nextProps) {
componentWillReceiveProps: function(nextProps) {
if (nextProps.mxEvent !== this.props.mxEvent) {
this._verifyEvent(nextProps.mxEvent);
}
},
shouldComponentUpdate: function (nextProps, nextState) {
shouldComponentUpdate: function(nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
return true;
}
@ -176,11 +168,18 @@ module.exports = React.createClass({
},
componentWillUnmount: function() {
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerificationChanged",
this.onDeviceVerificationChanged);
}
var client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged",
this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
},
/** called when the event is decrypted after we show it.
*/
_onDecrypted: function() {
// we need to re-verify the sending device.
this._verifyEvent(this.props.mxEvent);
this.forceUpdate();
},
onDeviceVerificationChanged: function(userId, device) {
@ -193,7 +192,7 @@ module.exports = React.createClass({
var verified = null;
if (mxEvent.isEncrypted()) {
verified = MatrixClientPeg.get().isEventSenderVerified(mxEvent);
verified = this.props.matrixClient.isEventSenderVerified(mxEvent);
}
this.setState({
@ -232,7 +231,7 @@ module.exports = React.createClass({
return false;
}
for (var j = 0; j < rA.length; j++) {
if (rA[j].userId !== rB[j].userId) {
if (rA[j].roomMember.userId !== rB[j].roomMember.userId) {
return false;
}
}
@ -246,11 +245,11 @@ module.exports = React.createClass({
},
shouldHighlight: function() {
var actions = MatrixClientPeg.get().getPushActionsForEvent(this.props.mxEvent);
var actions = this.props.matrixClient.getPushActionsForEvent(this.props.mxEvent);
if (!actions || !actions.tweaks) { return false; }
// don't show self-highlights from another of our clients
if (this.props.mxEvent.getSender() === MatrixClientPeg.get().credentials.userId)
if (this.props.mxEvent.getSender() === this.props.matrixClient.credentials.userId)
{
return false;
}
@ -260,11 +259,11 @@ module.exports = React.createClass({
onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
var buttonRect = e.target.getBoundingClientRect()
var buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
var x = buttonRect.right + window.pageXOffset;
var y = (buttonRect.top + (e.target.height / 2) + window.pageYOffset) - 19;
var y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
var self = this;
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
@ -288,19 +287,28 @@ module.exports = React.createClass({
getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = [];
var left = 0;
// It's possible that the receipt was sent several days AFTER the event.
// If it is, we want to display the complete date along with the HH:MM:SS,
// rather than just HH:MM:SS.
let dayAfterEvent = new Date(this.props.mxEvent.getTs());
dayAfterEvent.setDate(dayAfterEvent.getDate() + 1);
dayAfterEvent.setHours(0);
dayAfterEvent.setMinutes(0);
dayAfterEvent.setSeconds(0);
let dayAfterEventTime = dayAfterEvent.getTime();
var receipts = this.props.readReceipts || [];
for (var i = 0; i < receipts.length; ++i) {
var member = receipts[i];
var receipt = receipts[i];
var hidden = true;
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
hidden = false;
}
var userId = member.userId;
var userId = receipt.roomMember.userId;
var readReceiptInfo;
if (this.props.readReceiptMap) {
@ -312,15 +320,16 @@ module.exports = React.createClass({
}
//console.log("i = " + i + ", MAX_READ_AVATARS = " + MAX_READ_AVATARS + ", allReadAvatars = " + this.state.allReadAvatars + " visibility = " + style.visibility);
// add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift(
<ReadReceiptMarker key={userId} member={member}
<ReadReceiptMarker key={userId} member={receipt.roomMember}
leftOffset={left} hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this._suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts}
showFullTimestamp={receipt.ts >= dayAfterEventTime}
/>
);
@ -352,15 +361,16 @@ module.exports = React.createClass({
var mxEvent = this.props.mxEvent;
dispatcher.dispatch({
action: 'insert_displayname',
displayname: mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(),
displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''),
});
},
onCryptoClicked: function(e) {
var EncryptedEventDialog = sdk.getComponent("dialogs.EncryptedEventDialog");
var event = this.props.mxEvent;
Modal.createDialog(EncryptedEventDialog, {
Modal.createDialogAsync((cb) => {
require(['../../../async-components/views/dialogs/EncryptedEventDialog'], cb);
}, {
event: event,
});
},
@ -387,7 +397,7 @@ module.exports = React.createClass({
throw new Error("Event type not supported");
}
var e2eEnabled = MatrixClientPeg.get().isRoomEncrypted(this.props.mxEvent.getRoomId());
var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId());
var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
var classes = classNames({
@ -456,7 +466,7 @@ module.exports = React.createClass({
}
var editButton = (
<img className="mx_EventTile_editButton" src="img/icon_context_message.svg" width="19" height="19" alt="Options" title="Options" onClick={this.onEditClicked} />
<span className="mx_EventTile_editButton" title="Options" onClick={this.onEditClicked} />
);
var e2e;
@ -481,7 +491,7 @@ module.exports = React.createClass({
}
if (this.props.tileShape === "notif") {
var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
@ -554,4 +564,14 @@ module.exports = React.createClass({
);
}
},
});
}));
module.exports.haveTileForEvent = function(e) {
if (e.isRedacted()) return false;
if (eventTileTypes[e.getType()] == undefined) return false;
if (eventTileTypes[e.getType()] == 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== '';
} else {
return true;
}
};

View file

@ -60,13 +60,15 @@ module.exports = React.createClass({
},
componentDidMount: function() {
if (this.refs.description)
if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options);
}
},
componentDidUpdate: function() {
if (this.refs.description)
if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options);
}
},
componentWillUnmount: function() {
@ -116,7 +118,7 @@ module.exports = React.createClass({
if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={ image } onClick={ this.onImageClick }/>
</div>
</div>;
}
return (

View file

@ -24,8 +24,8 @@ export default class MemberDeviceInfo extends React.Component {
if (this.props.device.isBlocked()) {
indicator = (
<div className="mx_MemberDeviceInfo_blocked">
<img src="img/e2e-blocked.svg" width="12" height="12" style={{ marginLeft: "-1px" }} alt="Blocked"/>
<div className="mx_MemberDeviceInfo_blacklisted">
<img src="img/e2e-blocked.svg" width="12" height="12" style={{ marginLeft: "-1px" }} alt="Blacklisted"/>
</div>
);
} else if (this.props.device.isVerified()) {
@ -60,7 +60,7 @@ export default class MemberDeviceInfo extends React.Component {
</div>
);
}
};
}
MemberDeviceInfo.displayName = 'MemberDeviceInfo';
MemberDeviceInfo.propTypes = {

View file

@ -30,12 +30,12 @@ var classNames = require('classnames');
var dis = require("../../../dispatcher");
var Modal = require("../../../Modal");
var sdk = require('../../../index');
var UserSettingsStore = require('../../../UserSettingsStore');
var createRoom = require('../../../createRoom');
var DMRoomMap = require('../../../utils/DMRoomMap');
var Unread = require('../../../Unread');
var Receipt = require('../../../utils/Receipt');
var WithMatrixClient = require('../../../wrappers/WithMatrixClient');
import AccessibleButton from '../elements/AccessibleButton';
module.exports = WithMatrixClient(React.createClass({
displayName: 'MemberInfo',
@ -65,16 +65,14 @@ module.exports = WithMatrixClient(React.createClass({
updating: 0,
devicesLoading: true,
devices: null,
}
};
},
componentWillMount: function() {
this._cancelDeviceList = null;
// only display the devices list if our client supports E2E *and* the
// feature is enabled in the user settings
this._enableDevices = this.props.matrixClient.isCryptoEnabled() &&
UserSettingsStore.isFeatureEnabled("e2e_encryption");
// only display the devices list if our client supports E2E
this._enableDevices = this.props.matrixClient.isCryptoEnabled();
const cli = this.props.matrixClient;
cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
@ -205,7 +203,7 @@ module.exports = WithMatrixClient(React.createClass({
}
var cancelled = false;
this._cancelDeviceList = function() { cancelled = true; }
this._cancelDeviceList = function() { cancelled = true; };
var client = this.props.matrixClient;
var self = this;
@ -379,6 +377,7 @@ module.exports = WithMatrixClient(React.createClass({
// get out of sync if we force setState here!
console.log("Power change success");
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failure to change power level",
description: err.message
@ -386,7 +385,7 @@ module.exports = WithMatrixClient(React.createClass({
}
).finally(()=>{
this.setState({ updating: this.state.updating - 1 });
});
}).done();
this.props.onFinished();
},
@ -531,7 +530,7 @@ module.exports = WithMatrixClient(React.createClass({
});
},
onMemberAvatarClick: function () {
onMemberAvatarClick: function() {
var avatarUrl = this.props.member.user ? this.props.member.user.avatarUrl : this.props.member.events.member.getContent().avatar_url;
if(!avatarUrl) return;
@ -614,7 +613,7 @@ module.exports = WithMatrixClient(React.createClass({
mx_MemberInfo_createRoom_label: true,
mx_RoomTile_name: true,
});
const startNewChat = <div
const startNewChat = <AccessibleButton
className="mx_MemberInfo_createRoom"
onClick={this.onNewDMClick}
>
@ -622,7 +621,7 @@ module.exports = WithMatrixClient(React.createClass({
<img src="img/create-big.svg" width="26" height="26" />
</div>
<div className={labelClasses}><i>Start new chat</i></div>
</div>
</AccessibleButton>;
startChat = <div>
<h3>Direct chats</h3>
@ -637,26 +636,37 @@ module.exports = WithMatrixClient(React.createClass({
}
if (this.state.can.kick) {
kickButton = <div className="mx_MemberInfo_field" onClick={this.onKick}>
{ this.props.member.membership === "invite" ? "Disinvite" : "Kick" }
</div>;
const membership = this.props.member.membership;
const kickLabel = membership === "invite" ? "Disinvite" : "Kick";
kickButton = (
<AccessibleButton className="mx_MemberInfo_field"
onClick={this.onKick}>
{kickLabel}
</AccessibleButton>
);
}
if (this.state.can.ban) {
banButton = <div className="mx_MemberInfo_field" onClick={this.onBan}>
Ban
</div>;
banButton = (
<AccessibleButton className="mx_MemberInfo_field"
onClick={this.onBan}>
Ban
</AccessibleButton>
);
}
if (this.state.can.mute) {
var muteLabel = this.state.muted ? "Unmute" : "Mute";
muteButton = <div className="mx_MemberInfo_field" onClick={this.onMuteToggle}>
{muteLabel}
</div>;
const muteLabel = this.state.muted ? "Unmute" : "Mute";
muteButton = (
<AccessibleButton className="mx_MemberInfo_field"
onClick={this.onMuteToggle}>
{muteLabel}
</AccessibleButton>
);
}
if (this.state.can.toggleMod) {
var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator";
giveModButton = <div className="mx_MemberInfo_field" onClick={this.onModToggle}>
giveModButton = <AccessibleButton className="mx_MemberInfo_field" onClick={this.onModToggle}>
{giveOpLabel}
</div>
</AccessibleButton>;
}
// TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
@ -674,7 +684,7 @@ module.exports = WithMatrixClient(React.createClass({
{banButton}
{giveModButton}
</div>
</div>
</div>;
}
const memberName = this.props.member.name;
@ -684,7 +694,7 @@ module.exports = WithMatrixClient(React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<div className="mx_MemberInfo">
<img className="mx_MemberInfo_cancel" src="img/cancel.svg" width="18" height="18" onClick={this.onCancel}/>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}> <img src="img/cancel.svg" width="18" height="18"/></AccessibleButton>
<div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
</div>

View file

@ -32,7 +32,7 @@ var SHARE_HISTORY_WARNING =
Newly invited users will see the history of this room. <br/>
If you'd prefer invited users not to see messages that were sent before they joined, <br/>
turn off, 'Share message history with new users' in the settings for this room.
</span>
</span>;
module.exports = React.createClass({
displayName: 'MemberList',
@ -207,7 +207,7 @@ module.exports = React.createClass({
// For now we'll pretend this is any entity. It should probably be a separate tile.
var EntityTile = sdk.getComponent("rooms.EntityTile");
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "...";
var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "...";
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url="img/ellipsis.svg" name="..." width={36} height={36} />
@ -338,8 +338,8 @@ module.exports = React.createClass({
}
memberList.push(
<EntityTile key={e.getStateKey()} name={e.getContent().display_name} />
)
})
);
});
}
}

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
(this.user_last_modified_time === undefined ||
this.user_last_modified_time < nextProps.member.user.getLastModifiedTime())
) {
return true
return true;
}
return false;
},

View file

@ -222,13 +222,24 @@ export default class MessageComposer extends React.Component {
</div>
);
let e2eImg, e2eTitle, e2eClass;
if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) {
// FIXME: show a /!\ if there are untrusted devices in the room...
controls.push(
<img key="e2eIcon" className="mx_MessageComposer_e2eIcon" src="img/e2e-verified.svg" width="10" height="12" alt="Encrypted room"/>
);
e2eImg = 'img/e2e-verified.svg';
e2eTitle = 'Encrypted room';
e2eClass = 'mx_MessageComposer_e2eIcon';
} else {
e2eImg = 'img/e2e-unencrypted.svg';
e2eTitle = 'Unencrypted room';
e2eClass = 'mx_MessageComposer_e2eIcon mx_filterFlipColor';
}
controls.push(
<img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12"
alt={e2eTitle} title={e2eTitle}
/>
);
var callButton, videoCallButton, hangupButton;
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
@ -322,6 +333,7 @@ export default class MessageComposer extends React.Component {
const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name;
const className = classNames("mx_MessageComposer_format_button", {
mx_MessageComposer_format_button_disabled: disabled,
mx_filterFlipColor: true,
});
return <img className={className}
title={name}
@ -346,11 +358,11 @@ export default class MessageComposer extends React.Component {
<div style={{flex: 1}}></div>
<img title={`Turn Markdown ${this.state.inputState.isRichtextEnabled ? 'on' : 'off'}`}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown"
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title="Hide Text Formatting Toolbar"
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel"
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" />
</div>
</div>: null
@ -358,7 +370,7 @@ export default class MessageComposer extends React.Component {
</div>
);
}
};
}
MessageComposer.propTypes = {
tabComplete: React.PropTypes.any,

View file

@ -272,7 +272,7 @@ export default class MessageComposerInput extends React.Component {
break;
case 'quote': {
let {event: {content: {body, formatted_body}}} = payload.event || {};
let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`);
@ -443,12 +443,12 @@ export default class MessageComposerInput extends React.Component {
selection = this.state.editorState.getSelection();
let modifyFn = {
bold: text => `**${text}**`,
italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
strike: text => `~~${text}~~`,
code: text => `\`${text}\``,
blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''),
'bold': text => `**${text}**`,
'italic': text => `*${text}*`,
'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
'strike': text => `~~${text}~~`,
'code': text => `\`${text}\``,
'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
}[command];
@ -462,8 +462,9 @@ export default class MessageComposerInput extends React.Component {
}
}
if (newState == null)
if (newState == null) {
newState = RichUtils.handleKeyCommand(this.state.editorState, command);
}
if (newState != null) {
this.setEditorState(newState);
@ -523,7 +524,9 @@ export default class MessageComposerInput extends React.Component {
);
} else {
const md = new Markdown(contentText);
if (!md.isPlainText()) {
if (md.isPlainText()) {
contentText = md.toPlaintext();
} else {
contentHTML = md.toHTML();
}
}
@ -663,7 +666,7 @@ export default class MessageComposerInput extends React.Component {
const blockName = {
'code-block': 'code',
blockquote: 'quote',
'blockquote': 'quote',
'unordered-list-item': 'bullet',
'ordered-list-item': 'numbullet',
};
@ -716,7 +719,7 @@ export default class MessageComposerInput extends React.Component {
selection={selection} />
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator"
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
@ -738,7 +741,7 @@ export default class MessageComposerInput extends React.Component {
</div>
);
}
};
}
MessageComposerInput.propTypes = {
tabComplete: React.PropTypes.any,

View file

@ -192,7 +192,7 @@ module.exports = React.createClass({
}
},
onKeyDown: function (ev) {
onKeyDown: function(ev) {
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) {
var input = this.refs.textarea.value;
if (input.length === 0) {
@ -331,6 +331,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
}
else {
const contentText = mdown.toPlaintext();
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);

View file

@ -60,12 +60,18 @@ module.exports = React.createClass({
// callback for clicks on this RR
onClick: React.PropTypes.func,
// Timestamp when the receipt was read
timestamp: React.PropTypes.number,
// True to show the full date/time rather than just the time
showFullTimestamp: React.PropTypes.bool,
},
getDefaultProps: function() {
return {
leftOffset: 0,
}
};
},
getInitialState: function() {
@ -75,7 +81,7 @@ module.exports = React.createClass({
// position.
return {
suppressDisplay: !this.props.suppressAnimation,
}
};
},
componentWillUnmount: function() {
@ -162,17 +168,33 @@ module.exports = React.createClass({
visibility: this.props.hidden ? 'hidden' : 'visible',
};
let title;
if (this.props.timestamp) {
let suffix = " (" + this.props.member.userId + ")";
let ts = new Date(this.props.timestamp);
if (this.props.showFullTimestamp) {
// "15/12/2016, 7:05:45 PM (@alice:matrix.org)"
title = ts.toLocaleString() + suffix;
}
else {
// "7:05:45 PM (@alice:matrix.org)"
title = ts.toLocaleTimeString() + suffix;
}
}
return (
<Velociraptor
startStyles={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts} >
<MemberAvatar
member={this.props.member}
aria-hidden="true"
width={14} height={14} resizeMethod="crop"
style={style}
onClick={this.props.onClick}
title={title}
/>
</Velociraptor>
);
/* onClick={this.props.onClick} */
},
});

View file

@ -26,6 +26,8 @@ var rate_limited_func = require('../../../ratelimitedfunc');
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
import AccessibleButton from '../elements/AccessibleButton';
import {CancelButton} from './SimpleRoomHeader';
linkifyMatrix(linkify);
@ -182,8 +184,8 @@ module.exports = React.createClass({
'm.room.name', user_id
);
save_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save</div>
cancel_button = <div className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </div>
save_button = <AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save</AccessibleButton>;
cancel_button = <CancelButton onClick={this.props.onCancelClick}/>;
}
if (this.props.saving) {
@ -193,7 +195,7 @@ module.exports = React.createClass({
if (can_set_room_name) {
var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor");
name = <RoomNameEditor ref="nameEditor" room={this.props.room} />
name = <RoomNameEditor ref="nameEditor" room={this.props.room} />;
}
else {
var searchStatus;
@ -232,7 +234,7 @@ module.exports = React.createClass({
if (can_set_room_topic) {
var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor");
topic_el = <RoomTopicEditor ref="topicEditor" room={this.props.room} />
topic_el = <RoomTopicEditor ref="topicEditor" room={this.props.room} />;
} else {
var topic;
if (this.props.room) {
@ -275,9 +277,9 @@ module.exports = React.createClass({
var settings_button;
if (this.props.onSettingsClick) {
settings_button =
<div className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title="Settings">
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title="Settings">
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
</div>;
</AccessibleButton>;
}
// var leave_button;
@ -291,17 +293,17 @@ module.exports = React.createClass({
var forget_button;
if (this.props.onForgetClick) {
forget_button =
<div className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title="Forget room">
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title="Forget room">
<TintableSvg src="img/leave.svg" width="26" height="20"/>
</div>;
</AccessibleButton>;
}
var rightPanel_buttons;
if (this.props.collapsedRhs) {
rightPanel_buttons =
<div className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="<">
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="<">
<TintableSvg src="img/minimise.svg" width="10" height="16"/>
</div>
</AccessibleButton>;
}
var right_row;
@ -310,9 +312,9 @@ module.exports = React.createClass({
<div className="mx_RoomHeader_rightRow">
{ settings_button }
{ forget_button }
<div className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search">
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search">
<TintableSvg src="img/icons-search.svg" width="35" height="35"/>
</div>
</AccessibleButton>
{ rightPanel_buttons }
</div>;
}

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
isLoadingLeftRooms: false,
lists: {},
incomingCall: null,
}
};
},
componentWillMount: function() {
@ -338,7 +338,7 @@ module.exports = React.createClass({
// as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset)
var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset);
// Make sure we don't go too far up, if the headers aren't sticky
top = (top < scrollAreaOffset) ? scrollAreaOffset : top;
// make sure we don't go too far down, if the headers aren't sticky
@ -401,7 +401,7 @@ module.exports = React.createClass({
var stickyHeight = sticky.dataset.originalHeight;
var stickyHeader = sticky.childNodes[0];
var topStuckHeight = stickyHeight * i;
var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i)
var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i);
if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) {
// Top stickies
@ -520,7 +520,7 @@ module.exports = React.createClass({
collapsed={ self.props.collapsed }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />
onShowMoreRooms={ self.onShowMoreRooms } />;
}
}) }

View file

@ -58,7 +58,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
busy: false
}
};
},
componentWillMount: function() {
@ -94,9 +94,9 @@ module.exports = React.createClass({
if (this.props.invitedEmail) {
if (this.state.threePidFetchError) {
emailMatchBlock = <div className="error">
Riot was unable to ascertain that the address this invite was
Unable to ascertain that the address this invite was
sent to matches one associated with your account.
</div>
</div>;
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) {
emailMatchBlock =
<div className="mx_RoomPreviewBar_warning">
@ -107,7 +107,7 @@ module.exports = React.createClass({
This invitation was sent to <b><span className="email">{this.props.invitedEmail}</span></b>, which is not associated with this account.<br/>
You may wish to login with a different account, or add this email to this account.
</div>
</div>
</div>;
}
}
joinBlock = (

View file

@ -24,7 +24,6 @@ var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher");
var ScalarAuthClient = require("../../../ScalarAuthClient");
var ScalarMessaging = require('../../../ScalarMessaging');
var UserSettingsStore = require('../../../UserSettingsStore');
// parse a string as an integer; if the input is undefined, or cannot be parsed
// as an integer, return a default.
@ -81,16 +80,14 @@ module.exports = React.createClass({
console.error("Failed to get room visibility: " + err);
});
if (UserSettingsStore.isFeatureEnabled("integration_management")) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({
scalar_error: err
});
})
}
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({
scalar_error: err
});
});
dis.dispatch({
action: 'ui_opacity',
@ -255,7 +252,7 @@ module.exports = React.createClass({
return this.refs.url_preview_settings.saveSettings();
},
saveEncryption: function () {
saveEncryption: function() {
if (!this.refs.encrypt) { return q(); }
var encrypt = this.refs.encrypt.checked;
@ -407,7 +404,7 @@ module.exports = React.createClass({
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
return (roomState.mayClientSendStateEvent("m.room.join_rules", cli) &&
roomState.mayClientSendStateEvent("m.room.guest_access", cli))
roomState.mayClientSendStateEvent("m.room.guest_access", cli));
},
onManageIntegrations(ev) {
@ -462,11 +459,10 @@ module.exports = React.createClass({
description: (
<div>
<p>End-to-end encryption is in beta and may not be reliable.</p>
<p>You should <b>not</b> yet trust it to secure data. File transfers and calls are not yet encrypted.</p>
<p>You should <b>not</b> yet trust it to secure data.</p>
<p>Devices will <b>not</b> yet be able to decrypt history from before they joined the room.</p>
<p>Once encryption is enabled for a room it <b>cannot</b> be turned off again (for now).</p>
<p>Encrypted messages will not be visible on clients that do not yet implement encryption<br/>
(e.g. Riot/iOS and Riot/Android).</p>
<p>Encrypted messages will not be visible on clients that do not yet implement encryption.</p>
</div>
),
onFinished: confirm=>{
@ -478,10 +474,6 @@ module.exports = React.createClass({
},
_renderEncryptionSection: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
var isEncrypted = cli.isRoomEncrypted(this.props.room.roomId);
@ -518,7 +510,7 @@ module.exports = React.createClass({
var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings");
var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
var Loader = sdk.getComponent("elements.Spinner")
var Loader = sdk.getComponent("elements.Spinner");
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
@ -565,7 +557,7 @@ module.exports = React.createClass({
</div>;
}
else {
userLevelsSection = <div>No users have specific privileges in this room.</div>
userLevelsSection = <div>No users have specific privileges in this room.</div>;
}
var banned = this.props.room.getMembersWithMembership("ban");
@ -643,7 +635,7 @@ module.exports = React.createClass({
</label>);
})) : (self.state.tags && self.state.tags.join) ? self.state.tags.join(", ") : ""
}
</div>
</div>;
}
// If there is no history_visibility, it is assumed to be 'shared'.
@ -661,7 +653,7 @@ module.exports = React.createClass({
addressWarning =
<div className="mx_RoomSettings_warning">
To link to a room it must have <a href="#addresses">an address</a>.
</div>
</div>;
}
var inviteGuestWarning;
@ -672,7 +664,7 @@ module.exports = React.createClass({
this.setState({ join_rule: "invite", guest_access: "can_join" });
e.preventDefault();
}}>Click here to fix</a>.
</div>
</div>;
}
var integrationsButton;
@ -685,27 +677,26 @@ module.exports = React.createClass({
</span>
);
}
if (UserSettingsStore.isFeatureEnabled("integration_management")) {
if (this.scalarClient.hasCredentials()) {
integrationsButton = (
if (this.scalarClient.hasCredentials()) {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" onClick={ this.onManageIntegrations }>
Manage Integrations
</div>
);
} else if (this.state.scalar_error) {
integrationsButton = (
Manage Integrations
</div>
);
} else if (this.state.scalar_error) {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton_error" onClick={ this.onShowIntegrationsError }>
Integrations Error <img src="img/warning.svg" width="17"/>
{ integrationsError }
</div>
);
} else {
integrationsButton = (
Integrations Error <img src="img/warning.svg" width="17"/>
{ integrationsError }
</div>
);
} else {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" style={{ opacity: 0.5 }}>
Manage Integrations
</div>
);
}
Manage Integrations
</div>
);
}
return (
@ -796,7 +787,7 @@ module.exports = React.createClass({
roomId={this.props.room.roomId}
canSetCanonicalAlias={ roomState.mayClientSendStateEvent("m.room.canonical_alias", cli) }
canSetAliases={
true
true
/* Originally, we arbitrarily restricted creating aliases to room admins: roomState.mayClientSendStateEvent("m.room.aliases", cli) */
}
canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')}

View file

@ -26,6 +26,8 @@ var sdk = require('../../../index');
var ContextualMenu = require('../../structures/ContextualMenu');
var RoomNotifs = require('../../../RoomNotifs');
var FormattingUtils = require('../../../utils/FormattingUtils');
import AccessibleButton from '../elements/AccessibleButton';
var UserSettingsStore = require('../../../UserSettingsStore');
module.exports = React.createClass({
displayName: 'RoomTile',
@ -176,7 +178,8 @@ module.exports = React.createClass({
var self = this;
ContextualMenu.createMenu(RoomTagMenu, {
chevronOffset: 10,
menuColour: "#FFFFFF",
// XXX: fix horrid hardcoding
menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF",
left: x,
top: y,
room: this.props.room,
@ -219,7 +222,7 @@ module.exports = React.createClass({
var avatarContainerClasses = classNames({
'mx_RoomTile_avatar_container': true,
'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu,
})
});
var badgeClasses = classNames({
'mx_RoomTile_badge': true,
@ -286,8 +289,10 @@ module.exports = React.createClass({
var connectDragSource = this.props.connectDragSource;
var connectDropTarget = this.props.connectDropTarget;
let ret = (
<div className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div> { /* Only native elements can be wrapped in a DnD object. */}
<AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_menu" onClick={this.onAvatarClicked}>
<div className={avatarContainerClasses}>
@ -302,6 +307,7 @@ module.exports = React.createClass({
</div>
{/* { incomingCallBox } */}
{ tooltip }
</AccessibleButton>
</div>
);

View file

@ -118,7 +118,7 @@ var SearchableEntityList = React.createClass({
_createOverflowEntity: function(overflowCount, totalCount) {
var EntityTile = sdk.getComponent("rooms.EntityTile");
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "...";
var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "...";
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url="img/ellipsis.svg" name="..." width={36} height={36} />
@ -135,8 +135,8 @@ var SearchableEntityList = React.createClass({
<form onSubmit={this.onQuerySubmit} autoComplete="off">
<input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query}
onFocus= {() => { this.setState({ focused: true }) }}
onBlur= {() => { this.setState({ focused: false }) }}
onFocus= {() => { this.setState({ focused: true }); }}
onBlur= {() => { this.setState({ focused: false }); }}
placeholder={this.props.searchPlaceholderText} />
</form>
);

View file

@ -16,15 +16,27 @@ limitations under the License.
'use strict';
var React = require('react');
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
import React from 'react';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
// cancel button which is shared between room header and simple room header
export function CancelButton(props) {
const {onClick} = props;
return (
<AccessibleButton className='mx_RoomHeader_cancelButton' onClick={onClick}>
<img src="img/cancel.svg" className='mx_filterFlipColor'
width="18" height="18" alt="Cancel"/>
</AccessibleButton>
);
}
/*
* A stripped-down room header used for things like the user settings
* and room directory.
*/
module.exports = React.createClass({
export default React.createClass({
displayName: 'SimpleRoomHeader',
propTypes: {
@ -40,15 +52,15 @@ module.exports = React.createClass({
},
render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var cancelButton;
let cancelButton;
if (this.props.onCancelClick) {
cancelButton = <div className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </div>
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
}
var showRhsButton;
let showRhsButton;
/* // don't bother cluttering things up with this for now.
const TintableSvg = sdk.getComponent("elements.TintableSvg");
if (this.props.collapsedRhs) {
showRhsButton =
<div className="mx_RoomHeader_button" style={{ float: 'right' }} onClick={this.onShowRhsClick} title=">">
@ -70,4 +82,3 @@ module.exports = React.createClass({
);
},
});

View file

@ -38,7 +38,7 @@ module.exports = React.createClass({
var active = -1;
// FIXME: make presence data update whenever User.presence changes...
active = user.lastActiveAgo ?
active = user.lastActiveAgo ?
(Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) : -1;
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');

View file

@ -49,7 +49,7 @@ module.exports = React.createClass({
return {
avatarUrl: this.props.initialAvatarUrl,
phase: this.Phases.Display,
}
};
},
componentWillReceiveProps: function(newProps) {
@ -120,7 +120,7 @@ module.exports = React.createClass({
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
avatarImg = <BaseAvatar width={this.props.width} height={this.props.height} resizeMethod='crop'
name='?' idName={ MatrixClientPeg.get().getUserIdLocalpart() } url={this.state.avatarUrl} />
name='?' idName={ MatrixClientPeg.get().getUserIdLocalpart() } url={this.state.avatarUrl} />;
}
var uploadSection;
@ -130,7 +130,7 @@ module.exports = React.createClass({
Upload new:
<input type="file" onChange={this.onFileSelected}/>
{this.state.errorText}
</div>
</div>
);
}

View file

@ -18,7 +18,9 @@ limitations under the License.
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var sdk = require("../../../index");
import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'ChangePassword',
@ -59,32 +61,48 @@ module.exports = React.createClass({
getInitialState: function() {
return {
phase: this.Phases.Edit
}
};
},
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
var authDict = {
type: 'm.login.password',
user: cli.credentials.userId,
password: old_password
};
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Warning",
description:
<div>
Changing password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
This will be <a href="https://github.com/vector-im/riot-web/issues/2671">improved shortly</a>,
but for now be warned.
</div>,
button: "Continue",
onFinished: (confirmed) => {
if (confirmed) {
var authDict = {
type: 'm.login.password',
user: cli.credentials.userId,
password: old_password
};
this.setState({
phase: this.Phases.Uploading
this.setState({
phase: this.Phases.Uploading
});
var self = this;
cli.setPassword(authDict, new_password).then(function() {
self.props.onFinished();
}, function(err) {
self.props.onError(err);
}).finally(function() {
self.setState({
phase: self.Phases.Edit
});
}).done();
}
},
});
var self = this;
cli.setPassword(authDict, new_password).then(function() {
self.props.onFinished();
}, function(err) {
self.props.onError(err);
}).finally(function() {
self.setState({
phase: self.Phases.Edit
});
}).done();
},
onClickChange: function() {
@ -105,7 +123,7 @@ module.exports = React.createClass({
render: function() {
var rowClassName = this.props.rowClassName;
var rowLabelClassName = this.props.rowLabelClassName;
var rowInputClassName = this.props.rowInputClassName
var rowInputClassName = this.props.rowInputClassName;
var buttonClassName = this.props.buttonClassName;
switch (this.state.phase) {
@ -136,9 +154,10 @@ module.exports = React.createClass({
<input id="password2" type="password" ref="confirm_input" />
</div>
</div>
<div className={buttonClassName} onClick={this.onClickChange}>
<AccessibleButton className={buttonClassName}
onClick={this.onClickChange}>
Change Password
</div>
</AccessibleButton>
</div>
);
case this.Phases.Uploading:

View file

@ -88,7 +88,7 @@ export default class DevicesPanel extends React.Component {
const removed_id = device.device_id;
this.setState((state, props) => {
const newDevices = state.devices.filter(
d => { return d.device_id != removed_id }
d => { return d.device_id != removed_id; }
);
return { devices: newDevices };
});
@ -98,7 +98,7 @@ export default class DevicesPanel extends React.Component {
var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
return (
<DevicesPanelEntry key={device.device_id} device={device}
onDeleted={()=>{this._onDeviceDeleted(device)}} />
onDeleted={()=>{this._onDeviceDeleted(device);}} />
);
}

View file

@ -15,12 +15,9 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import q from 'q';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DateUtils from '../../../DateUtils';
import Modal from '../../../Modal';
export default class DevicesPanelEntry extends React.Component {
@ -61,7 +58,7 @@ export default class DevicesPanelEntry extends React.Component {
if (this._unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure
throw e;
throw error;
}
// pop up an interactive auth dialog
@ -121,7 +118,7 @@ export default class DevicesPanelEntry extends React.Component {
let deleteButton;
if (this.state.deleteError) {
deleteButton = <div className="error">{this.state.deleteError}</div>
deleteButton = <div className="error">{this.state.deleteError}</div>;
} else {
deleteButton = (
<div className="mx_textButton"

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
var React = require("react");
var Notifier = require("../../../Notifier");
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
module.exports = React.createClass({

View file

@ -124,7 +124,7 @@ module.exports = React.createClass({
return this.refs.video;
},
render: function(){
render: function() {
var VideoView = sdk.getComponent('voip.VideoView');
var voice;

View file

@ -114,7 +114,7 @@ module.exports = React.createClass({
<VideoFeed ref="remote" onResize={this.props.onResize}
maxHeight={maxVideoHeight} />
</div>
<div className="mx_VideoView_localVideoFeed">
<div className="mx_VideoView_localVideoFeed">
<VideoFeed ref="local"/>
</div>
</div>