Merge branch 'develop' into feature-composer-emoji

This commit is contained in:
Aviral Dasgupta 2016-07-23 19:15:06 +05:30
commit b7555f49ea
44 changed files with 1057 additions and 311 deletions

180
README.md
View file

@ -3,65 +3,85 @@ matrix-react-sdk
This is a react-based SDK for inserting a Matrix chat/voip client into a web page. This is a react-based SDK for inserting a Matrix chat/voip client into a web page.
This package provides the logic and 'controller' parts for the UI components. This This package provides the React components needed to build a Matrix web client
forms one part of a complete matrix client, but it not useable in isolation. It using React. It is not useable in isolation, and instead must must be used from
must be used from a 'skin'. A skin provides: a 'skin'. A skin provides:
* The HTML for the UI components (in the form of React `render` methods) * Customised implementations of presentation components.
* The CSS for this HTML * Custom CSS
* The containing application * The containing application
* Zero or more 'modules' containing non-UI functionality * Zero or more 'modules' containing non-UI functionality
Skins are modules are exported from such a package in the `lib` directory. **WARNING: As of July 2016, the skinning abstraction is broken due to rapid
`lib/skins` contains one directory per-skin, named after the skin, and the development of `matrix-react-sdk` to meet the needs of Vector, the first app
`modules` directory contains modules as their javascript files. to be built on top of the SDK** (https://github.com/vector-im/vector-web).
Right now `matrix-react-sdk` depends on some functionality from `vector-web`
(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour
(grep for 'vector'). This layering will be fixed asap once Vector development
has stabilised, but for now we do not advise trying to create new skins for
matrix-react-sdk until the layers are clearly separated again.
A basic skin is provided in the matrix-react-skin package. This also contains In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should
a minimal application that instantiates the basic skin making a working matrix be considered as a single project (for instance, matrix-react-sdk bugs
client. are currently filed against vector-im/vector-web rather than this project).
You can use matrix-react-sdk directly, but to do this you would have to provide Developer Guide
'views' for each UI component. To get started quickly, use matrix-react-skin. ===============
How to customise the SDK Platform Targets:
======================== * Chrome, Firefox and Safari.
* Edge should also work, but we're not testing it proactively.
* WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
* Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk) SDKs.
The SDK formerly used the 'atomic' design pattern as seen at http://patternlab.io to All code lands on the `develop` branch - `master` is only used for stable releases.
encourage a very modular and reusable architecture, making it easy to **Please file PRs against `develop`!!**
customise and use UI widgets independently of the rest of the SDK and your app.
So unfortunately at the moment this document does not describe how to customize your UI! Please follow the standard Matrix contributor's guide:
https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst
###This is the old description for the atomic design pattern: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst
In practice this means: Whilst the layering separation between matrix-react-sdk and Vector is broken
(as of July 2016), code should be committed as follows:
* All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components
* Vector-specific components: https://github.com/vector-im/vector-web/tree/master/src/components
* In practice, `matrix-react-sdk` is still evolving so fast that the maintenance
burden of customising and overriding these components for Vector can seriously
impede development. So right now, there should be very few (if any) customisations for Vector.
* CSS for Matrix SDK components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk
* CSS for Vector-specific overrides and components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/vector-web
* The UI of the app is strictly split up into a hierarchy of components. React components in matrix-react-sdk are come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the
* Each component has its own: more complicated business logic of the app, delegating their actual presentation
* View object defined as a React javascript class containing embedded rendering to stateless 'view' components. For instance, the RoomView component
HTML expressed in React's JSX notation. that orchestrates the act of visualising the contents of a given Matrix chat room
* CSS file, which defines the styling specific to that component. tracks lots of state for its child components which it passes into them for visual
rendering via props.
* Components are loosely grouped into the 5 levels outlined by atomic design:
* atoms: fundamental building blocks (e.g. a timestamp tag)
* molecules: "group of atoms which functions together as a unit"
(e.g. a message in a chat timeline)
* organisms: "groups of molecules (and atoms) which form a distinct section
of a UI" (e.g. a view of a chat room)
* templates: "a reusable configuration of organisms" - used to combine and
style organisms into a well-defined global look and feel
* pages: specific instances of templates.
Good separation between the components is maintained by adopting various best Good separation between the components is maintained by adopting various best
practices that anyone working with the SDK needs to be be aware of and uphold: practices that anyone working with the SDK needs to be be aware of and uphold:
* Views are named with upper camel case (e.g. molecules/MessageTile.js) * Components are named with upper camel case (e.g. views/rooms/EventTile.js)
* The view's CSS file MUST have the same name (e.g. molecules/MessageTile.css) * They are organised in a typically two-level hierarchy - first whether the
component is a view or a structure, and then a broad functional grouping
(e.g. 'rooms' here)
* After creating a new component you must run `npm run reskindex` to regenerate
the `component-index.js` for the SDK (used in future for skinning)
* The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css).
CSS for matrix-react-sdk currently resides in
https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk.
* Per-view CSS is optional - it could choose to inherit all its styling from * Per-view CSS is optional - it could choose to inherit all its styling from
the context of the rest of the app, although this is unusual for any but the context of the rest of the app, although this is unusual for any but
the simplest atoms and molecules. structural components (lacking presentation logic) and the simplest view
components.
* The view MUST *only* refer to the CSS rules defined in its own CSS file. * The view MUST *only* refer to the CSS rules defined in its own CSS file.
'Stealing' styling information from other components (including parents) 'Stealing' styling information from other components (including parents)
@ -82,9 +102,10 @@ In practice this means:
* We deliberately use vanilla CSS 3.0 to avoid adding any more magic * We deliberately use vanilla CSS 3.0 to avoid adding any more magic
dependencies into the mix than we already have. App developers are welcome dependencies into the mix than we already have. App developers are welcome
to use whatever floats their boat however. to use whatever floats their boat however. In future we'll start using
css-next to pull in features like CSS variable support.
* The CSS for a component can however override the rules for child components. * The CSS for a component can override the rules for child components.
For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override
styles of RoomTiles when viewed in the context of a RoomList view. styles of RoomTiles when viewed in the context of a RoomList view.
Overrides *must* be scoped to the View's CSS class - i.e. don't just define Overrides *must* be scoped to the View's CSS class - i.e. don't just define
@ -98,30 +119,36 @@ In practice this means:
generally not cool and stop the component from being reused easily in generally not cool and stop the component from being reused easily in
different places. different places.
* We don't use the atomify library itself, as React already provides most Originally `matrix-react-sdk` followed the Atomic design pattern as per
of the modularity requirements it brings to the table. http://patternlab.io to try to encourage a modular architecture. However, we
found that the grouping of components into atoms/molecules/organisms
made them harder to find relative to a functional split, and didn't emphasise
the distinction between 'structural' and 'view' components, so we backed away
from it.
With all this in mind, here's how you go about skinning the react SDK UI Github Issues
components to embed a Matrix client into your app: =============
* Create a new NPM project. Be sure to directly depend on react, (otherwise All issues should be filed under https://github.com/vector-im/vector-web/issues
you can end up with two copies of react). for now.
* Create an index.js file that sets up react. Add require statements for
React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the OUTDATED: To Create Your Own Skin
SDK and call Render. This can be a skin provided by a separate package or =================================
a skin in the same package.
* Add a way to build your project: we suggest copying the scripts block **This is ALL LIES currently, as skinning is currently broken - see the WARNING
from matrix-react-skin (which uses babel and webpack). You could use section at the top of this readme.**
different tools but remember that at least the skins and modules of
your project should end up in plain (ie. non ES6, non JSX) javascript in Skins are modules are exported from such a package in the `lib` directory.
the lib directory at the end of the build process, as well as any `lib/skins` contains one directory per-skin, named after the skin, and the
packaging that you might do. `modules` directory contains modules as their javascript files.
* Create an index.html file pulling in your compiled javascript and the
CSS bundle from the skin you use. For now, you'll also need to manually A basic skin is provided in the matrix-react-skin package. This also contains
import CSS from any skins that your skin inherts from. a minimal application that instantiates the basic skin making a working matrix
client.
You can use matrix-react-sdk directly, but to do this you would have to provide
'views' for each UI component. To get started quickly, use matrix-react-skin.
To Create Your Own Skin
=======================
To actually change the look of a skin, you can create a base skin (which To actually change the look of a skin, you can create a base skin (which
does not use views from any other skin) or you can make a derived skin. does not use views from any other skin) or you can make a derived skin.
Note that derived skins are currently experimental: for example, the CSS Note that derived skins are currently experimental: for example, the CSS
@ -145,3 +172,22 @@ Now you have the basis of a skin, you need to generate a skindex.json file. The
you add an npm script to run this, as in matrix-react-skin. you add an npm script to run this, as in matrix-react-skin.
For more specific detail on any of these steps, look at matrix-react-skin. For more specific detail on any of these steps, look at matrix-react-skin.
Alternative instructions:
* Create a new NPM project. Be sure to directly depend on react, (otherwise
you can end up with two copies of react).
* Create an index.js file that sets up react. Add require statements for
React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the
SDK and call Render. This can be a skin provided by a separate package or
a skin in the same package.
* Add a way to build your project: we suggest copying the scripts block
from matrix-react-skin (which uses babel and webpack). You could use
different tools but remember that at least the skins and modules of
your project should end up in plain (ie. non ES6, non JSX) javascript in
the lib directory at the end of the build process, as well as any
packaging that you might do.
* Create an index.html file pulling in your compiled javascript and the
CSS bundle from the skin you use. For now, you'll also need to manually
import CSS from any skins that your skin inherts from.

162
code_style.md Normal file
View file

@ -0,0 +1,162 @@
Matrix JavaScript/ECMAScript Style Guide
========================================
The intention of this guide is to make Matrix's JavaScript codebase clean,
consistent with other popular JavaScript styles and consistent with the rest of
the Matrix codebase. For reference, the Matrix Python style guide can be found
at https://github.com/matrix-org/synapse/blob/master/docs/code_style.rst
This document reflects how we would like Matrix JavaScript code to look, with
acknowledgement that a significant amount of code is written to older
standards.
Write applications in modern ECMAScript and use a transpiler where necessary to
target older platforms. When writing library code, consider carefully whether
to write in ES5 to allow all JavaScript application to use the code directly or
writing in modern ECMAScript and using a transpile step to generate the file
that applications can then include. There are significant benefits in being
able to use modern ECMAScript, although the tooling for doing so can be awkward
for library code, especially with regard to translating source maps and line
number throgh from the original code to the final application.
General Style
-------------
- 4 spaces to indent, for consistency with Matrix Python.
- 120 columns per line, but try to keep JavaScript code around the 80 column mark.
Inline JSX in particular can be nicer with more columns per line.
- No trailing whitespace at end of lines.
- Don't indent empty lines.
- One newline at the end of the file.
- Unix newlines, never `\r`
- Indent similar to our python code: break up long lines at logical boundaries,
more than one argument on a line is OK
- Use semicolons, for consistency with node.
- UpperCamelCase for class and type names
- lowerCamelCase for functions and variables.
- Single line ternary operators are fine.
- UPPER_CAMEL_CASE for constants
- Single quotes for strings by default, for consistency with most JavaScript styles:
```javascript
"bad" // Bad
'good' // Good
```
- Use parentheses or `\`` instead of '\\' for line continuation where ever possible
- Open braces on the same line (consistent with Node):
```javascript
if (x) {
console.log("I am a fish"); // Good
}
if (x)
{
console.log("I am a fish"); // Bad
}
```
- Spaces after `if`, `for`, `else` etc, no space around the condition:
```javascript
if (x) {
console.log("I am a fish"); // Good
}
if(x) {
console.log("I am a fish"); // Bad
}
if ( x ) {
console.log("I am a fish"); // Bad
}
```
- Declare one variable per var statement (consistent with Node). Unless they
are simple and closely related. If you put the next declaration on a new line,
treat yourself to another `var`:
```javascript
var key = "foo",
comparator = function(x, y) {
return x - y;
}; // Bad
var key = "foo";
var comparator = function(x, y) {
return x - y;
}; // Good
var x = 0, y = 0; // Fine
var x = 0;
var y = 0; // Also fine
```
- A single line `if` is fine, all others have braces. This prevents errors when adding to the code.:
```javascript
if (x) return true; // Fine
if (x) {
return true; // Also fine
}
if (x)
return true; // Not fine
```
- Terminate all multi-line lists, object literals, imports and ideally function calls with commas (if using a transpiler). Note that trailing function commas require explicit configuration in babel at time of writing:
```javascript
var mascots = [
"Patrick",
"Shirley",
"Colin",
"Susan",
"Sir Arthur David" // Bad
];
var mascots = [
"Patrick",
"Shirley",
"Colin",
"Susan",
"Sir Arthur David", // Good
];
```
- Use `null`, `undefined` etc consistently with node:
Boolean variables and functions should always be either true or false. Don't set it to 0 unless it's supposed to be a number.
When something is intentionally missing or removed, set it to null.
If returning a boolean, type coerce:
```javascript
function hasThings() {
return !!length; // bad
return new Boolean(length); // REALLY bad
return Boolean(length); // good
}
```
Don't set things to undefined. Reserve that value to mean "not yet set to anything."
Boolean objects are verboten.
- Use JSDoc
ECMAScript
----------
- Use `const` unless you need a re-assignable variable. This ensures things you don't want to be re-assigned can't be.
- Be careful migrating files to newer syntax.
- Don't mix `require` and `import` in the same file. Either stick to the old style or change them all.
- Likewise, don't mix things like class properties and `MyClass.prototype.MY_CONSTANT = 42;`
- Be careful mixing arrow functions and regular functions, eg. if one function in a promise chain is an
arrow function, they probably all should be.
- Apart from that, newer ES features should be used whenever the author deems them to be appropriate.
- Flow annotations are welcome and encouraged.
React
-----
- Use ES6 classes, although bear in mind a lot of code uses createClass.
- Pull out functions in props to the class, generally as specific event handlers:
```jsx
<Foo onClick={function(ev) {doStuff();}}> // Bad
<Foo onClick={(ev) => {doStuff();}}> // Equally bad
<Foo onClick={this.doStuff}> // Better
<Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff
```
- Think about whether your component really needs state: are you duplicating
information in component state that could be derived from the model?

View file

@ -14,8 +14,8 @@
}, },
"scripts": { "scripts": {
"reskindex": "reskindex -h header", "reskindex": "reskindex -h header",
"build": "babel src -d lib --source-maps", "build": "babel src -d lib --source-maps --stage 1",
"start": "babel src -w -d lib --source-maps", "start": "babel src -w -d lib --source-maps --stage 1",
"lint": "eslint src/", "lint": "eslint src/",
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
"clean": "rimraf lib", "clean": "rimraf lib",
@ -42,10 +42,10 @@
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.0.1", "react": "^15.2.1",
"react-addons-css-transition-group": "^15.1.0", "react-addons-css-transition-group": "^15.2.1",
"react-dom": "^15.0.1", "react-dom": "^15.2.1",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#dbf0abf",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.0.0"

View file

@ -38,11 +38,13 @@ class AddThreepid {
*/ */
addEmailAddress(emailAddress, bind) { addEmailAddress(emailAddress, bind) {
this.bind = bind; this.bind = bind;
return MatrixClientPeg.get().requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.httpStatus) { if (err.errcode == 'M_THREEPID_IN_USE') {
err.message = "This email address is already in use";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View file

@ -52,6 +52,36 @@ function infoForImageFile(imageFile) {
return deferred.promise; return deferred.promise;
} }
function infoForVideoFile(videoFile) {
var deferred = q.defer();
// Load the file into an html element
var video = document.createElement("video");
var reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
// Once ready, returns its size
video.onloadedmetadata = function() {
deferred.resolve({
w: video.videoWidth,
h: video.videoHeight
});
};
video.onerror = function(e) {
deferred.reject(e);
};
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(videoFile);
return deferred.promise;
}
class ContentMessages { class ContentMessages {
constructor() { constructor() {
this.inprogress = []; this.inprogress = [];
@ -81,6 +111,12 @@ class ContentMessages {
} else if (file.type.indexOf('audio/') == 0) { } else if (file.type.indexOf('audio/') == 0) {
content.msgtype = 'm.audio'; content.msgtype = 'm.audio';
def.resolve(); def.resolve();
} else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video';
infoForVideoFile(file).then(function (videoInfo) {
extend(content.info, videoInfo);
def.resolve();
});
} else { } else {
content.msgtype = 'm.file'; content.msgtype = 'm.file';
def.resolve(); def.resolve();

View file

@ -186,7 +186,7 @@ module.exports = {
* *
* highlights: optional list of words to highlight, ordered by longest word first * highlights: optional list of words to highlight, ordered by longest word first
* *
* opts.highlightLink: optional href to add to highlights * opts.highlightLink: optional href to add to highlighted words
*/ */
bodyToHtml: function(content, highlights, opts) { bodyToHtml: function(content, highlights, opts) {
opts = opts || {}; opts = opts || {};
@ -223,8 +223,10 @@ module.exports = {
let match = EMOJI_REGEX.exec(contentBodyTrimmed); let match = EMOJI_REGEX.exec(contentBodyTrimmed);
let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
let className = classNames('markdown-body', { const className = classNames({
'emoji-body': emojiBody, 'mx_EventTile_body': true,
'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtml,
}); });
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} />; return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} />;
}, },

View file

@ -97,35 +97,20 @@ class MatrixClient {
// FIXME, XXX: this all seems very convoluted :( // FIXME, XXX: this all seems very convoluted :(
// //
// if we replace the singleton using URLs we bypass our createClientForPeg()
// global helper function... but if we replace it using
// an access_token we don't?
//
// Why do we have this peg wrapper rather than just MatrixClient.get()? // Why do we have this peg wrapper rather than just MatrixClient.get()?
// Why do we name MatrixClient as MatrixClientPeg when we export it? // Why do we name MatrixClient as MatrixClientPeg when we export it?
// //
// -matthew // -matthew
replaceUsingUrls(hs_url, is_url) { replaceUsingUrls(hs_url, is_url) {
matrixClient = Matrix.createClient({ this.replaceClient(hs_url, is_url);
baseUrl: hs_url,
idBaseUrl: is_url
});
// XXX: factor this out with the localStorage setting in replaceUsingAccessToken
if (localStorage) {
try {
localStorage.setItem("mx_hs_url", hs_url);
localStorage.setItem("mx_is_url", is_url);
} catch (e) {
console.warn("Error using local storage: can't persist HS/IS URLs!");
}
} else {
console.warn("No local storage available: can't persist HS/IS URLs!");
}
} }
replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) { replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) {
this.replaceClient(hs_url, is_url, user_id, access_token, isGuest);
}
replaceClient(hs_url, is_url, user_id, access_token, isGuest) {
if (localStorage) { if (localStorage) {
try { try {
localStorage.clear(); localStorage.clear();

View file

@ -48,11 +48,13 @@ class PasswordReset {
*/ */
resetPassword(emailAddress, newPassword) { resetPassword(emailAddress, newPassword) {
this.password = newPassword; this.password = newPassword;
return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.httpStatus) { if (err.errcode == 'M_THREEPID_NOT_FOUND') {
err.message = "This email address was not found";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View file

@ -152,7 +152,10 @@ class Register extends Signup {
console.log("Active flow => %s", JSON.stringify(flow)); console.log("Active flow => %s", JSON.stringify(flow));
var flowStage = self.firstUncompletedStage(flow); var flowStage = self.firstUncompletedStage(flow);
if (flowStage != self.activeStage) { if (flowStage != self.activeStage) {
return self.startStage(flowStage); return self.startStage(flowStage).catch(function(err) {
self.setStep('START');
throw err;
});
} }
} }
} }

View file

@ -170,7 +170,7 @@ class EmailIdentityStage extends Stage {
encodeURIComponent(this.signupInstance.getServerData().session); encodeURIComponent(this.signupInstance.getServerData().session);
var self = this; var self = this;
return this.client.requestEmailToken( return this.client.requestRegisterEmailToken(
this.signupInstance.email, this.signupInstance.email,
this.clientSecret, this.clientSecret,
1, // TODO: Multiple send attempts? 1, // TODO: Multiple send attempts?
@ -186,8 +186,8 @@ class EmailIdentityStage extends Stage {
var e = { var e = {
isFatal: true isFatal: true
}; };
if (error.errcode == 'THREEPID_IN_USE') { if (error.errcode == 'M_THREEPID_IN_USE') {
e.message = "Email in use"; e.message = "This email address is already registered";
} else { } else {
e.message = 'Unable to contact the given identity server'; e.message = 'Unable to contact the given identity server';
} }

View file

@ -13,7 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var Entry = require("./TabCompleteEntries").Entry;
import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries';
import SlashCommands from './SlashCommands';
import MatrixClientPeg from './MatrixClientPeg';
const DELAY_TIME_MS = 1000; const DELAY_TIME_MS = 1000;
const KEY_TAB = 9; const KEY_TAB = 9;
@ -45,23 +48,39 @@ class TabComplete {
this.isFirstWord = false; // true if you tab-complete on the first word this.isFirstWord = false; // true if you tab-complete on the first word
this.enterTabCompleteTimerId = null; this.enterTabCompleteTimerId = null;
this.inPassiveMode = false; this.inPassiveMode = false;
// Map tracking ordering of the room members.
// userId: integer, highest comes first.
this.memberTabOrder = {};
// monotonically increasing counter used for tracking ordering of members
this.memberOrderSeq = 0;
} }
/** /**
* @param {Entry[]} completeList * Call this when a a UI element representing a tab complete entry has been clicked
* @param {entry} The entry that was clicked
*/ */
setCompletionList(completeList) { onEntryClick(entry) {
this.list = completeList;
if (this.opts.onClickCompletes) { if (this.opts.onClickCompletes) {
// assign onClick listeners for each entry to complete the text this.completeTo(entry);
this.list.forEach((l) => {
l.onClick = () => {
this.completeTo(l);
}
});
} }
} }
loadEntries(room) {
this._makeEntries(room);
this._initSorting(room);
this._sortEntries();
}
onMemberSpoke(member) {
if (this.memberTabOrder[member.userId] === undefined) {
this.list.push(new MemberEntry(member));
}
this.memberTabOrder[member.userId] = this.memberOrderSeq++;
this._sortEntries();
}
/** /**
* @param {DOMElement} * @param {DOMElement}
*/ */
@ -307,6 +326,54 @@ class TabComplete {
this.opts.onStateChange(this.completing); this.opts.onStateChange(this.completing);
} }
} }
_sortEntries() {
// largest comes first
const KIND_ORDER = {
command: 1,
member: 2,
};
this.list.sort((a, b) => {
const kindOrderDifference = KIND_ORDER[b.kind] - KIND_ORDER[a.kind];
if (kindOrderDifference != 0) {
return kindOrderDifference;
}
if (a.kind == 'member') {
let orderA = this.memberTabOrder[a.member.userId];
let orderB = this.memberTabOrder[b.member.userId];
if (orderA === undefined) orderA = -1;
if (orderB === undefined) orderB = -1;
return orderB - orderA;
}
// anything else we have no ordering for
return 0;
});
}
_makeEntries(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
this.list = MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
);
}
_initSorting(room) {
this.memberTabOrder = {};
this.memberOrderSeq = 0;
for (const ev of room.getLiveTimeline().getEvents()) {
this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++;
}
}
}; };
module.exports = TabComplete; module.exports = TabComplete;

View file

@ -69,6 +69,7 @@ class Entry {
class CommandEntry extends Entry { class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) { constructor(cmd, cmdWithArgs) {
super(cmdWithArgs); super(cmdWithArgs);
this.kind = 'command';
this.cmd = cmd; this.cmd = cmd;
} }
@ -95,6 +96,7 @@ class MemberEntry extends Entry {
constructor(member) { constructor(member) {
super(member.name || member.userId); super(member.name || member.userId);
this.member = member; this.member = member;
this.kind = 'member';
} }
getImageJsx() { getImageJsx() {
@ -114,24 +116,7 @@ class MemberEntry extends Entry {
} }
MemberEntry.fromMemberList = function(members) { MemberEntry.fromMemberList = function(members) {
return members.sort(function(a, b) { return members.map(function(m) {
var userA = a.user;
var userB = b.user;
if (userA && !userB) {
return -1; // a comes first
}
else if (!userA && userB) {
return 1; // b comes first
}
else if (!userA && !userB) {
return 0; // don't care
}
else { // both User objects exist
var lastActiveAgoA = userA.lastActiveAgo || Number.MAX_SAFE_INTEGER;
var lastActiveAgoB = userB.lastActiveAgo || Number.MAX_SAFE_INTEGER;
return lastActiveAgoA - lastActiveAgoB;
}
}).map(function(m) {
return new MemberEntry(m); return new MemberEntry(m);
}); });
} }

View file

@ -113,6 +113,35 @@ module.exports = {
}); });
}, },
getUrlPreviewsDisabled: function() {
var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
return (event && event.getContent().disable);
},
setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", {
disable: disabled
});
},
getSyncedSettings: function() {
var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings");
return event ? event.getContent() : {};
},
getSyncedSetting: function(type) {
var settings = this.getSyncedSettings();
return settings[type];
},
setSyncedSetting: function(type, value) {
var settings = this.getSyncedSettings();
settings[type] = value;
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings);
},
isFeatureEnabled: function(feature: string): boolean { isFeatureEnabled: function(feature: string): boolean {
return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true'; return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true';
}, },

View file

@ -74,6 +74,8 @@ module.exports.components['views.messages.TextualEvent'] = require('./components
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody');
module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings'); module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings');
module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings'); module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings');
module.exports.components['views.room_settings.UrlPreviewSettings'] = require('./components/views/room_settings/UrlPreviewSettings');
module.exports.components['views.rooms.Autocomplete'] = require('./components/views/rooms/Autocomplete');
module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel'); module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel');
module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile');
module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile');

View file

@ -390,7 +390,7 @@ module.exports = React.createClass({
// FIXME: controller shouldn't be loading a view :( // FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader); var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() { d.then(function() {
modal.close(); modal.close();

View file

@ -44,6 +44,9 @@ module.exports = React.createClass({
// ID of an event to highlight. If undefined, no event will be highlighted. // ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId: React.PropTypes.string, highlightedEventId: React.PropTypes.string,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// event after which we should show a read marker // event after which we should show a read marker
readMarkerEventId: React.PropTypes.string, readMarkerEventId: React.PropTypes.string,
@ -365,6 +368,7 @@ module.exports = React.createClass({
onWidgetLoad={this._onWidgetLoad} onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap} readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting} checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status} eventSendStatus={mxEv.status}
last={last} isSelectedEvent={highlight}/> last={last} isSelectedEvent={highlight}/>

View file

@ -26,9 +26,9 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// the room this statusbar is representing. // the room this statusbar is representing.
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
// a list of TabCompleteEntries.Entry objects // a TabComplete object
tabCompleteEntries: React.PropTypes.array, tabComplete: React.PropTypes.object.isRequired,
// the number of messages which have arrived since we've been scrolled up // the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number, numUnreadMessages: React.PropTypes.number,
@ -208,11 +208,11 @@ module.exports = React.createClass({
); );
} }
if (this.props.tabCompleteEntries) { if (this.props.tabComplete.isTabCompleting()) {
return ( return (
<div className="mx_RoomStatusBar_tabCompleteBar"> <div className="mx_RoomStatusBar_tabCompleteBar">
<div className="mx_RoomStatusBar_tabCompleteWrapper"> <div className="mx_RoomStatusBar_tabCompleteWrapper">
<TabCompleteBar entries={this.props.tabCompleteEntries} /> <TabCompleteBar tabComplete={this.props.tabComplete} />
<div className="mx_RoomStatusBar_tabCompleteEol" title="->|"> <div className="mx_RoomStatusBar_tabCompleteEol" title="->|">
<TintableSvg src="img/eol.svg" width="22" height="16"/> <TintableSvg src="img/eol.svg" width="22" height="16"/>
Auto-complete Auto-complete
@ -233,7 +233,7 @@ module.exports = React.createClass({
<a className="mx_RoomStatusBar_resend_link" <a className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onResendAllClick }> onClick={ this.props.onResendAllClick }>
Resend all Resend all
</a> or <a </a> or <a
className="mx_RoomStatusBar_resend_link" className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onCancelAllClick }> onClick={ this.props.onCancelAllClick }>
cancel all cancel all
@ -247,7 +247,7 @@ module.exports = React.createClass({
// unread count trumps who is typing since the unread count is only // unread count trumps who is typing since the unread count is only
// set when you've scrolled up // set when you've scrolled up
if (this.props.numUnreadMessages) { if (this.props.numUnreadMessages) {
var unreadMsgs = this.props.numUnreadMessages + " new message" + var unreadMsgs = this.props.numUnreadMessages + " new message" +
(this.props.numUnreadMessages > 1 ? "s" : ""); (this.props.numUnreadMessages > 1 ? "s" : "");
return ( return (
@ -291,5 +291,5 @@ module.exports = React.createClass({
{content} {content}
</div> </div>
); );
}, },
}); });

View file

@ -31,10 +31,7 @@ var Modal = require("../../Modal");
var sdk = require('../../index'); var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete"); var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Tinter = require("../../Tinter"); var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc'); var rate_limited_func = require('../../ratelimitedfunc');
@ -141,6 +138,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("accountData", this.onAccountData);
this.tabComplete = new TabComplete({ this.tabComplete = new TabComplete({
allowLooping: false, allowLooping: false,
@ -204,6 +202,9 @@ module.exports = React.createClass({
user_is_in_room = this.state.room.hasMembershipState( user_is_in_room = this.state.room.hasMembershipState(
MatrixClientPeg.get().credentials.userId, 'join' MatrixClientPeg.get().credentials.userId, 'join'
); );
this._updateAutoComplete();
this.tabComplete.loadEntries(this.state.room);
} }
if (!user_is_in_room && this.state.roomId) { if (!user_is_in_room && this.state.roomId) {
@ -267,6 +268,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
} }
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
@ -338,6 +340,10 @@ module.exports = React.createClass({
// ignore events for other rooms // ignore events for other rooms
if (!this.state.room || room.roomId != this.state.room.roomId) return; if (!this.state.room || room.roomId != this.state.room.roomId) return;
if (ev.getType() === "org.matrix.room.preview_urls") {
this._updatePreviewUrlVisibility(room);
}
// ignore anything but real-time updates at the end of the room: // ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes. // updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return; if (toStartOfTimeline || !data || !data.liveEvent) return;
@ -357,12 +363,21 @@ module.exports = React.createClass({
}); });
} }
} }
// update the tab complete list as it depends on who most recently spoke,
// and that has probably just changed
if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since
// its results are currently ordered purely by search score.
}
}, },
// called when state.room is first initialised (either at initial load, // called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
_onRoomLoaded: function(room) { _onRoomLoaded: function(room) {
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room);
}, },
_calculatePeekRules: function(room) { _calculatePeekRules: function(room) {
@ -381,6 +396,42 @@ module.exports = React.createClass({
} }
}, },
_updatePreviewUrlVisibility: function(room) {
// console.log("_updatePreviewUrlVisibility");
// check our per-room overrides
var roomPreviewUrls = room.getAccountData("org.matrix.room.preview_urls");
if (roomPreviewUrls && roomPreviewUrls.getContent().disable !== undefined) {
this.setState({
showUrlPreview: !roomPreviewUrls.getContent().disable
});
return;
}
// check our global disable override
var userRoomPreviewUrls = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
if (userRoomPreviewUrls && userRoomPreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false
});
return;
}
// check the room state event
var roomStatePreviewUrls = room.currentState.getStateEvents('org.matrix.room.preview_urls', '');
if (roomStatePreviewUrls && roomStatePreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false
});
return;
}
// otherwise, we assume they're on.
this.setState({
showUrlPreview: true
});
},
onRoom: function(room) { onRoom: function(room) {
// This event is fired when the room is 'stored' by the JS SDK, which // This event is fired when the room is 'stored' by the JS SDK, which
// means it's now a fully-fledged room object ready to be used, so // means it's now a fully-fledged room object ready to be used, so
@ -411,14 +462,23 @@ module.exports = React.createClass({
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}, },
onRoomAccountData: function(room, event) { onAccountData: function(event) {
if (room.roomId == this.props.roomId) { if (event.getType() === "org.matrix.preview_urls" && this.state.room) {
if (event.getType === "org.matrix.room.color_scheme") { this._updatePreviewUrlVisibility(this.state.room);
}
},
onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) {
if (event.getType() === "org.matrix.room.color_scheme") {
var color_scheme = event.getContent(); var color_scheme = event.getContent();
// XXX: we should validate the event // XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData"); console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} }
else if (event.getType() === "org.matrix.room.preview_urls") {
this._updatePreviewUrlVisibility(room);
}
} }
}, },
@ -434,7 +494,8 @@ module.exports = React.createClass({
} }
// a member state changed in this room, refresh the tab complete list // a member state changed in this room, refresh the tab complete list
this._updateTabCompleteList(); this.tabComplete.loadEntries(this.state.room);
this._updateAutoComplete();
// if we are now a member of the room, where we were not before, that // if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking // means we have finished joining a room we were previously peeking
@ -499,8 +560,6 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
this.onResize(); this.onResize();
this._updateTabCompleteList();
// XXX: EVIL HACK to autofocus inviting on empty rooms. // XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer. // We use the setTimeout to avoid racing with focus_composer.
if (this.state.room && if (this.state.room &&
@ -518,24 +577,6 @@ module.exports = React.createClass({
} }
}, },
_updateTabCompleteList: function() {
var cli = MatrixClientPeg.get();
if (!this.state.room) {
return;
}
var members = this.state.room.getJoinedMembers().filter(function(member) {
if (member.userId !== cli.credentials.userId) return true;
});
UserProvider.getInstance().setUserList(members);
this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
)
);
},
componentDidUpdate: function() { componentDidUpdate: function() {
if (this.refs.roomView) { if (this.refs.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView); var roomView = ReactDOM.findDOMNode(this.refs.roomView);
@ -1260,6 +1301,14 @@ module.exports = React.createClass({
} }
}, },
_updateAutoComplete: function() {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = this.state.room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() { render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1373,12 +1422,10 @@ module.exports = React.createClass({
statusBar = <UploadBar room={this.state.room} /> statusBar = <UploadBar room={this.state.room} />
} else if (!this.state.searchResults) { } else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
var tabEntries = this.tabComplete.isTabCompleting() ?
this.tabComplete.peek(6) : null;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
tabCompleteEntries={tabEntries} tabComplete={this.tabComplete}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
hasUnsentMessages={this.state.hasUnsentMessages} hasUnsentMessages={this.state.hasUnsentMessages}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline} atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
@ -1511,6 +1558,8 @@ module.exports = React.createClass({
hideMessagePanel = true; hideMessagePanel = true;
} }
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
var messagePanel = ( var messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef} <TimelinePanel ref={this._gatherTimelinePanelRef}
room={this.state.room} room={this.state.room}
@ -1520,6 +1569,7 @@ module.exports = React.createClass({
eventPixelOffset={this.props.eventPixelOffset} eventPixelOffset={this.props.eventPixelOffset}
onScroll={ this.onMessageListScroll } onScroll={ this.onMessageListScroll }
onReadMarkerUpdated={ this._updateTopUnreadMessagesBar } onReadMarkerUpdated={ this._updateTopUnreadMessagesBar }
showUrlPreview = { this.state.showUrlPreview }
opacity={ this.props.opacity } opacity={ this.props.opacity }
/>); />);

View file

@ -71,6 +71,9 @@ var TimelinePanel = React.createClass({
// half way down the viewport. // half way down the viewport.
eventPixelOffset: React.PropTypes.number, eventPixelOffset: React.PropTypes.number,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func, onScroll: React.PropTypes.func,
@ -934,6 +937,7 @@ var TimelinePanel = React.createClass({
readMarkerEventId={ this.state.readMarkerEventId } readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible } readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate } suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
ourUserId={ MatrixClientPeg.get().credentials.userId } ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom } stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll } onScroll={ this.onMessageListScroll }

View file

@ -214,9 +214,10 @@ module.exports = React.createClass({
onFinished: this.onEmailDialogFinished, onFinished: this.onEmailDialogFinished,
}); });
}, (err) => { }, (err) => {
this.setState({email_add_pending: false});
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Unable to add email address", title: "Unable to add email address",
description: err.toString() description: err.message
}); });
}); });
ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); ReactDOM.findDOMNode(this.refs.add_threepid_input).blur();
@ -261,6 +262,63 @@ module.exports = React.createClass({
}); });
}, },
_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>
</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>
);
},
_renderDeviceInfo: function() { _renderDeviceInfo: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null; return null;
@ -378,7 +436,7 @@ module.exports = React.createClass({
this._renderLabs = function () { this._renderLabs = function () {
let features = LABS_FEATURES.map(feature => ( let features = LABS_FEATURES.map(feature => (
<div key={feature.id}> <div key={feature.id} className="mx_UserSettings_toggle">
<input <input
type="checkbox" type="checkbox"
id={feature.id} id={feature.id}
@ -452,6 +510,8 @@ module.exports = React.createClass({
{notification_area} {notification_area}
{this._renderUserInterfaceSettings()}
{this._renderDeviceInfo()} {this._renderDeviceInfo()}
{this._renderLabs()} {this._renderLabs()}

View file

@ -54,6 +54,16 @@ module.exports = React.createClass({
return { return {
busy: false, busy: false,
errorText: null, errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
}; };
}, },
@ -108,7 +118,8 @@ module.exports = React.createClass({
var self = this; var self = this;
this.setState({ this.setState({
errorText: "", errorText: "",
busy: true busy: true,
formVals: formVals,
}); });
if (formVals.username !== this.props.username) { if (formVals.username !== this.props.username) {
@ -228,11 +239,15 @@ module.exports = React.createClass({
break; // NOP break; // NOP
case "Register.START": case "Register.START":
case "Register.STEP_m.login.dummy": case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
registerStep = ( registerStep = (
<RegistrationForm <RegistrationForm
showEmail={true} showEmail={true}
defaultUsername={this.props.username} defaultUsername={this.state.formVals.username}
defaultEmail={this.props.email} defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} /> onRegisterClick={this.onFormSubmit} />

View file

@ -59,7 +59,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={this.props.focus}> <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
{this.props.button} {this.props.button}
</button> </button>
</div> </div>

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
Sign out? Sign out?
</div> </div>
<div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }> <div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }>
<button autoFocus onClick={this.logOut}>Sign Out</button> <button className="mx_Dialog_primary" autoFocus onClick={this.logOut}>Sign Out</button>
<button onClick={this.cancelPrompt}>Cancel</button> <button onClick={this.cancelPrompt}>Cancel</button>
</div> </div>
</div> </div>

View file

@ -63,7 +63,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}> <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={true}>
Cancel Cancel
</button> </button>
<button onClick={this.onRegisterClicked}> <button onClick={this.onRegisterClicked}>

View file

@ -56,7 +56,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onOk} autoFocus={this.props.focus}> <button className="mx_Dialog_primary" onClick={this.onOk} autoFocus={this.props.focus}>
{this.props.button} {this.props.button}
</button> </button>

View file

@ -76,7 +76,7 @@ module.exports = React.createClass({
/> />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<input type="submit" value="Set" /> <input className="mx_Dialog_primary" type="submit" value="Set" />
</div> </div>
</form> </form>
</div> </div>

View file

@ -86,7 +86,7 @@ module.exports = React.createClass({
<button onClick={this.onCancel}> <button onClick={this.onCancel}>
Cancel Cancel
</button> </button>
<button onClick={this.onOk}> <button className="mx_Dialog_primary" onClick={this.onOk}>
{this.props.button} {this.props.button}
</button> </button>
</div> </div>

View file

@ -35,8 +35,16 @@ module.exports = React.createClass({
displayName: 'RegistrationForm', displayName: 'RegistrationForm',
propTypes: { propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string, defaultEmail: React.PropTypes.string,
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string,
// A username that will be used if no username is entered.
// Specifying this param will also warn the user that entering
// a different username will cause a fresh account to be generated.
guestUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool, showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number, minPasswordLength: React.PropTypes.number,
onError: React.PropTypes.func, onError: React.PropTypes.func,
@ -55,10 +63,6 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
email: this.props.defaultEmail,
username: null,
password: null,
passwordConfirm: null,
fieldValid: {} fieldValid: {}
}; };
}, },
@ -103,7 +107,7 @@ module.exports = React.createClass({
_doSubmit: function() { _doSubmit: function() {
var promise = this.props.onRegisterClick({ var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.defaultUsername, username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(), password: this.refs.password.value.trim(),
email: this.refs.email.value.trim() email: this.refs.email.value.trim()
}); });
@ -144,7 +148,7 @@ module.exports = React.createClass({
break; break;
case FIELD_USERNAME: case FIELD_USERNAME:
// XXX: SPEC-1 // XXX: SPEC-1
var username = this.refs.username.value.trim() || this.props.defaultUsername; var username = this.refs.username.value.trim() || this.props.guestUsername;
if (encodeURIComponent(username) != username) { if (encodeURIComponent(username) != username) {
this.markFieldValid( this.markFieldValid(
field_id, field_id,
@ -225,7 +229,7 @@ module.exports = React.createClass({
emailSection = ( emailSection = (
<input className="mx_Login_field" type="text" ref="email" <input className="mx_Login_field" type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)" autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.state.email} defaultValue={this.props.defaultEmail}
style={this._styleField(FIELD_EMAIL)} style={this._styleField(FIELD_EMAIL)}
onBlur={function() {self.validateField(FIELD_EMAIL)}} /> onBlur={function() {self.validateField(FIELD_EMAIL)}} />
); );
@ -237,8 +241,8 @@ module.exports = React.createClass({
} }
var placeholderUserName = "User name"; var placeholderUserName = "User name";
if (this.props.defaultUsername) { if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.defaultUsername + ")" placeholderUserName += " (default: " + this.props.guestUsername + ")"
} }
return ( return (
@ -247,23 +251,23 @@ module.exports = React.createClass({
{emailSection} {emailSection}
<br /> <br />
<input className="mx_Login_field" type="text" ref="username" <input className="mx_Login_field" type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.state.username} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
style={this._styleField(FIELD_USERNAME)} style={this._styleField(FIELD_USERNAME)}
onBlur={function() {self.validateField(FIELD_USERNAME)}} /> onBlur={function() {self.validateField(FIELD_USERNAME)}} />
<br /> <br />
{ this.props.defaultUsername ? { this.props.guestUsername ?
<div className="mx_Login_fieldLabel">Setting a user name will create a fresh account</div> : null <div className="mx_Login_fieldLabel">Setting a user name will create a fresh account</div> : null
} }
<input className="mx_Login_field" type="password" ref="password" <input className="mx_Login_field" type="password" ref="password"
style={this._styleField(FIELD_PASSWORD)} style={this._styleField(FIELD_PASSWORD)}
onBlur={function() {self.validateField(FIELD_PASSWORD)}} onBlur={function() {self.validateField(FIELD_PASSWORD)}}
placeholder="Password" defaultValue={this.state.password} /> placeholder="Password" defaultValue={this.props.defaultPassword} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="passwordConfirm" <input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password" placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)} style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}} onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} /> defaultValue={this.props.defaultPassword} />
<br /> <br />
{registerButton} {registerButton}
</form> </form>

View file

@ -34,7 +34,7 @@ module.exports = React.createClass({
} }
if (fullWidth < thumbWidth && fullHeight < thumbHeight) { if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied // no scaling needs to be applied
return fullHeight; return 1;
} }
var widthMulti = thumbWidth / fullWidth; var widthMulti = thumbWidth / fullWidth;
var heightMulti = thumbHeight / fullHeight; var heightMulti = thumbHeight / fullHeight;

View file

@ -38,6 +38,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* callback called when dynamic content in events are loaded */ /* callback called when dynamic content in events are loaded */
onWidgetLoad: React.PropTypes.func, onWidgetLoad: React.PropTypes.func,
}, },
@ -71,6 +74,7 @@ module.exports = React.createClass({
return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights} return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />; onWidgetLoad={this.props.onWidgetLoad} />;
}, },
}); });

View file

@ -39,6 +39,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* callback for when our widget has loaded */ /* callback for when our widget has loaded */
onWidgetLoad: React.PropTypes.func, onWidgetLoad: React.PropTypes.func,
}, },
@ -56,34 +59,47 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
linkifyElement(this.refs.content, linkifyMatrix.options); linkifyElement(this.refs.content, linkifyMatrix.options);
this.calculateUrlPreview();
var links = this.findLinks(this.refs.content.children);
if (links.length) {
this.setState({ links: links.map((link)=>{
return link.getAttribute("href");
})});
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
}
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
}, },
componentDidUpdate: function() {
this.calculateUrlPreview();
},
shouldComponentUpdate: function(nextProps, nextState) { shouldComponentUpdate: function(nextProps, nextState) {
//console.log("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
// exploit that events are immutable :) // exploit that events are immutable :)
// ...and that .links is only ever set in componentDidMount and never changes
return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.highlights !== this.props.highlights || nextProps.highlights !== this.props.highlights ||
nextProps.highlightLink !== this.props.highlightLink || nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextState.links !== this.state.links || nextState.links !== this.state.links ||
nextState.widgetHidden !== this.state.widgetHidden); nextState.widgetHidden !== this.state.widgetHidden);
}, },
calculateUrlPreview: function() {
//console.log("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
if (this.props.showUrlPreview && !this.state.links.length) {
var links = this.findLinks(this.refs.content.children);
if (links.length) {
this.setState({ links: links.map((link)=>{
return link.getAttribute("href");
})});
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
}
}
},
findLinks: function(nodes) { findLinks: function(nodes) {
var links = []; var links = [];
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
@ -163,12 +179,14 @@ module.exports = React.createClass({
render: function() { render: function() {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent(); var content = mxEvent.getContent();
var body = HtmlUtils.bodyToHtml(content, this.props.highlights, var body = HtmlUtils.bodyToHtml(content, this.props.highlights, {});
{highlightLink: this.props.highlightLink});
if (this.props.highlightLink) {
body = <a href={ this.props.highlightLink }>{ body }</a>;
}
var widgets; var widgets;
if (this.state.links.length && !this.state.widgetHidden) { if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{ widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget return <LinkPreviewWidget

View file

@ -83,13 +83,11 @@ module.exports = React.createClass({
alias: this.state.canonicalAlias alias: this.state.canonicalAlias
}, "" }, ""
) )
); );
} }
// save new aliases for m.room.aliases // save new aliases for m.room.aliases
var aliasOperations = this.getAliasOperations(); var aliasOperations = this.getAliasOperations();
var promises = [];
for (var i = 0; i < aliasOperations.length; i++) { for (var i = 0; i < aliasOperations.length; i++) {
var alias_operation = aliasOperations[i]; var alias_operation = aliasOperations[i];
console.log("alias %s %s", alias_operation.place, alias_operation.val); console.log("alias %s %s", alias_operation.place, alias_operation.val);
@ -301,7 +299,7 @@ module.exports = React.createClass({
<div className="mx_RoomSettings_addAlias"> <div className="mx_RoomSettings_addAlias">
<img src="img/plus.svg" width="14" height="14" alt="Add" <img src="img/plus.svg" width="14" height="14" alt="Add"
onClick={ self.onAliasAdded.bind(self, undefined) }/> onClick={ self.onAliasAdded.bind(self, undefined) }/>
</div> </div>
</div> : "" </div> : ""
} }
</div> </div>

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({
data.primary_color = scheme.primary_color; data.primary_color = scheme.primary_color;
data.secondary_color = scheme.secondary_color; data.secondary_color = scheme.secondary_color;
data.index = this._getColorIndex(data); data.index = this._getColorIndex(data);
if (data.index === -1) { if (data.index === -1) {
// append the unrecognised colours to our palette // append the unrecognised colours to our palette
data.index = ROOM_COLORS.length; data.index = ROOM_COLORS.length;

View file

@ -0,0 +1,157 @@
/*
Copyright 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 q = require("q");
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require("../../../index");
var Modal = require("../../../Modal");
var UserSettingsStore = require('../../../UserSettingsStore');
module.exports = React.createClass({
displayName: 'UrlPreviewSettings',
propTypes: {
room: React.PropTypes.object,
},
getInitialState: function() {
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
var roomPreviewUrls = this.props.room.currentState.getStateEvents('org.matrix.room.preview_urls', '');
var userPreviewUrls = this.props.room.getAccountData("org.matrix.room.preview_urls");
return {
globalDisableUrlPreview: (roomPreviewUrls && roomPreviewUrls.getContent().disable) || false,
userDisableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === true)) || false,
userEnableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === false)) || false,
};
},
componentDidMount: function() {
this.originalState = Object.assign({}, this.state);
},
saveSettings: function() {
var promises = [];
if (this.state.globalDisableUrlPreview !== this.originalState.globalDisableUrlPreview) {
console.log("UrlPreviewSettings: Updating room's preview_urls state event");
promises.push(
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId, "org.matrix.room.preview_urls", {
disable: this.state.globalDisableUrlPreview
}, ""
)
);
}
var content = undefined;
if (this.state.userDisableUrlPreview !== this.originalState.userDisableUrlPreview) {
console.log("UrlPreviewSettings: Disabling user's per-room preview_urls");
content = this.state.userDisableUrlPreview ? { disable : true } : {};
}
if (this.state.userEnableUrlPreview !== this.originalState.userEnableUrlPreview) {
console.log("UrlPreviewSettings: Enabling user's per-room preview_urls");
if (!content || content.disable === undefined) {
content = this.state.userEnableUrlPreview ? { disable : false } : {};
}
}
if (content) {
promises.push(
MatrixClientPeg.get().setRoomAccountData(
this.props.room.roomId, "org.matrix.room.preview_urls", content
)
);
}
console.log("UrlPreviewSettings: saveSettings: " + JSON.stringify(promises));
return promises;
},
onGlobalDisableUrlPreviewChange: function() {
this.setState({
globalDisableUrlPreview: this.refs.globalDisableUrlPreview.checked ? true : false,
});
},
onUserEnableUrlPreviewChange: function() {
this.setState({
userDisableUrlPreview: false,
userEnableUrlPreview: this.refs.userEnableUrlPreview.checked ? true : false,
});
},
onUserDisableUrlPreviewChange: function() {
this.setState({
userDisableUrlPreview: this.refs.userDisableUrlPreview.checked ? true : false,
userEnableUrlPreview: false,
});
},
render: function() {
var self = this;
var roomState = this.props.room.currentState;
var cli = MatrixClientPeg.get();
var maySetRoomPreviewUrls = roomState.mayClientSendStateEvent('org.matrix.room.preview_urls', cli);
var disableRoomPreviewUrls;
if (maySetRoomPreviewUrls) {
disableRoomPreviewUrls =
<label>
<input type="checkbox" ref="globalDisableUrlPreview"
onChange={ this.onGlobalDisableUrlPreviewChange }
checked={ this.state.globalDisableUrlPreview } />
Disable URL previews by default for participants in this room
</label>
}
else {
disableRoomPreviewUrls =
<label>
URL previews are { this.state.globalDisableUrlPreview ? "disabled" : "enabled" } by default for participants in this room.
</label>
}
return (
<div className="mx_RoomSettings_toggles">
<h3>URL Previews</h3>
<label>
You have <a href="#/settings">{ UserSettingsStore.getUrlPreviewsDisabled() ? 'disabled' : 'enabled' }</a> URL previews by default.
</label>
{ disableRoomPreviewUrls }
<label>
<input type="checkbox" ref="userEnableUrlPreview"
onChange={ this.onUserEnableUrlPreviewChange }
checked={ this.state.userEnableUrlPreview } />
Enable URL previews for this room (affects only you)
</label>
<label>
<input type="checkbox" ref="userDisableUrlPreview"
onChange={ this.onUserDisableUrlPreviewChange }
checked={ this.state.userDisableUrlPreview } />
Disable URL previews for this room (affects only you)
</label>
</div>
);
}
});

View file

@ -29,6 +29,23 @@ var PRESENCE_CLASS = {
"unavailable": "mx_EntityTile_unavailable" "unavailable": "mx_EntityTile_unavailable"
}; };
function presenceClassForMember(presenceState, lastActiveAgo) {
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState == 'offline') {
if (lastActiveAgo) {
return PRESENCE_CLASS['offline'] + '_beenactive';
} else {
return PRESENCE_CLASS['offline'] + '_neveractive';
}
} else if (presenceState) {
return PRESENCE_CLASS[presenceState];
} else {
return PRESENCE_CLASS['offline'] + '_neveractive';
}
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'EntityTile', displayName: 'EntityTile',
@ -79,7 +96,10 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline"; const presenceClass = presenceClassForMember(
this.props.presenceState, this.props.presenceLastActiveAgo
);
var mainClassName = "mx_EntityTile "; var mainClassName = "mx_EntityTile ";
mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : ""); mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : "");
var nameEl; var nameEl;

View file

@ -101,6 +101,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* is this the focused event */ /* is this the focused event */
isSelectedEvent: React.PropTypes.bool, isSelectedEvent: React.PropTypes.bool,
@ -359,6 +362,8 @@ module.exports = React.createClass({
var SenderProfile = sdk.getComponent('messages.SenderProfile'); var SenderProfile = sdk.getComponent('messages.SenderProfile');
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
//console.log("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
var content = this.props.mxEvent.getContent(); var content = this.props.mxEvent.getContent();
var msgtype = content.msgtype; var msgtype = content.msgtype;
@ -420,6 +425,7 @@ module.exports = React.createClass({
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType ref="tile" mxEvent={this.props.mxEvent} highlights={this.props.highlights} <EventTileType ref="tile" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} /> onWidgetLoad={this.props.onWidgetLoad} />
</div> </div>
</div> </div>

View file

@ -37,17 +37,14 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._room = MatrixClientPeg.get().getRoom(this.props.roomId); var cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
this._emailEntity = null; this._emailEntity = null;
// Load the complete user list for inviting new users
// TODO: Keep this list bleeding-edge up-to-date. Practically speaking, // we have to update the list whenever membership changes
// it will do for now not being updated as random new users join different // particularly to avoid bug https://github.com/vector-im/vector-web/issues/1813
// rooms as this list will be reloaded every room swap. this._updateList();
if (this._room) {
this._userList = MatrixClientPeg.get().getUsers().filter((u) => {
return !this._room.hasMembershipState(u.userId, "join");
});
}
}, },
componentDidMount: function() { componentDidMount: function() {
@ -55,6 +52,28 @@ module.exports = React.createClass({
this.onSearchQueryChanged(''); this.onSearchQueryChanged('');
}, },
componentWillUnmount: function() {
var cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember);
}
},
_updateList: function() {
this._room = MatrixClientPeg.get().getRoom(this.props.roomId);
// Load the complete user list for inviting new users
if (this._room) {
this._userList = MatrixClientPeg.get().getUsers().filter((u) => {
return (!this._room.hasMembershipState(u.userId, "join") &&
!this._room.hasMembershipState(u.userId, "invite"));
});
}
},
onRoomStateMember: function(ev, state, member) {
this._updateList();
},
onInvite: function(ev) { onInvite: function(ev) {
this.props.onInvite(this._input); this.props.onInvite(this._input);
}, },

View file

@ -61,12 +61,16 @@ module.exports = React.createClass({
updating: 0, updating: 0,
devicesLoading: true, devicesLoading: true,
devices: null, devices: null,
existingOneToOneRoomId: null,
} }
}, },
componentWillMount: function() { componentWillMount: function() {
this._cancelDeviceList = null; this._cancelDeviceList = null;
this.setState({
existingOneToOneRoomId: this.getExistingOneToOneRoomId()
});
}, },
componentDidMount: function() { componentDidMount: function() {
@ -90,6 +94,44 @@ module.exports = React.createClass({
} }
}, },
getExistingOneToOneRoomId: function() {
var self = this;
var rooms = MatrixClientPeg.get().getRooms();
var userIds = [
this.props.member.userId,
MatrixClientPeg.get().credentials.userId
];
var existingRoomId;
// roomId can be null here because of a hack in MatrixChat.onUserClick where we
// abuse this to view users rather than room members.
var currentMembers;
if (this.props.member.roomId) {
var currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId);
currentMembers = currentRoom.getJoinedMembers();
}
// reuse the first private 1:1 we find
existingRoomId = null;
for (var i = 0; i < rooms.length; i++) {
// don't try to reuse public 1:1 rooms
var join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", '');
if (join_rules && join_rules.getContent().join_rule === 'public') continue;
var members = rooms[i].getJoinedMembers();
if (members.length === 2 &&
userIds.indexOf(members[0].userId) !== -1 &&
userIds.indexOf(members[1].userId) !== -1)
{
existingRoomId = rooms[i].roomId;
break;
}
}
return existingRoomId;
},
onDeviceVerificationChanged: function(userId, device) { onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.member.userId) { if (userId == this.props.member.userId) {
// no need to re-download the whole thing; just update our copy of // no need to re-download the whole thing; just update our copy of
@ -349,66 +391,29 @@ module.exports = React.createClass({
onChatClick: function() { onChatClick: function() {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// TODO: keep existingOneToOneRoomId updated if we see any room member changes anywhere
var useExistingOneToOneRoom = this.state.existingOneToOneRoomId && (this.state.existingOneToOneRoomId !== this.props.member.roomId);
// check if there are any existing rooms with just us and them (1:1) // check if there are any existing rooms with just us and them (1:1)
// If so, just view that room. If not, create a private room with them. // If so, just view that room. If not, create a private room with them.
var self = this; if (this.state.existingOneToOneRoomId) {
var rooms = MatrixClientPeg.get().getRooms();
var userIds = [
this.props.member.userId,
MatrixClientPeg.get().credentials.userId
];
var existingRoomId;
// roomId can be null here because of a hack in MatrixChat.onUserClick where we
// abuse this to view users rather than room members.
var currentMembers;
if (this.props.member.roomId) {
var currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId);
currentMembers = currentRoom.getJoinedMembers();
}
// if we're currently in a 1:1 with this user, start a new chat
if (currentMembers && currentMembers.length === 2 &&
userIds.indexOf(currentMembers[0].userId) !== -1 &&
userIds.indexOf(currentMembers[1].userId) !== -1)
{
existingRoomId = null;
}
// otherwise reuse the first private 1:1 we find
else {
existingRoomId = null;
for (var i = 0; i < rooms.length; i++) {
// don't try to reuse public 1:1 rooms
var join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", '');
if (join_rules && join_rules.getContent().join_rule === 'public') continue;
var members = rooms[i].getJoinedMembers();
if (members.length === 2 &&
userIds.indexOf(members[0].userId) !== -1 &&
userIds.indexOf(members[1].userId) !== -1)
{
existingRoomId = rooms[i].roomId;
break;
}
}
}
if (existingRoomId) {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: existingRoomId room_id: this.state.existingOneToOneRoomId,
}); });
this.props.onFinished(); this.props.onFinished();
} }
else { else {
self.setState({ updating: self.state.updating + 1 }); this.setState({ updating: this.state.updating + 1 });
createRoom({ createRoom({
createOpts: { createOpts: {
invite: [this.props.member.userId], invite: [this.props.member.userId],
}, },
}).finally(function() { }).finally(() => {
self.props.onFinished(); this.props.onFinished();
self.setState({ updating: self.state.updating - 1 }); this.setState({ updating: this.state.updating - 1 });
}).done(); }).done();
} }
}, },
@ -553,7 +558,22 @@ module.exports = React.createClass({
if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
// FIXME: we're referring to a vector component from react-sdk // FIXME: we're referring to a vector component from react-sdk
var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile');
startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg" label="Start chat" onClick={ this.onChatClick }/>
var label;
if (this.state.existingOneToOneRoomId) {
if (this.state.existingOneToOneRoomId == this.props.member.roomId) {
label = "Start new direct chat";
}
else {
label = "Go to direct chat";
}
}
else {
label = "Start direct chat";
}
startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg"
label={ label } onClick={ this.onChatClick }/>
} }
if (this.state.updating) { if (this.state.updating) {

View file

@ -54,7 +54,7 @@ module.exports = React.createClass({
this.memberDict = this.getMemberDict(); this.memberDict = this.getMemberDict();
state.members = this.roomMembers(INITIAL_LOAD_NUM_MEMBERS); state.members = this.roomMembers();
return state; return state;
}, },
@ -64,7 +64,10 @@ module.exports = React.createClass({
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("RoomState.events", this.onRoomStateEvent); cli.on("RoomState.events", this.onRoomStateEvent);
cli.on("Room", this.onRoom); // invites cli.on("Room", this.onRoom); // invites
cli.on("User.presence", this.onUserPresence); // We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
// cli.on("Room.timeline", this.onRoomTimeline); // cli.on("Room.timeline", this.onRoomTimeline);
}, },
@ -75,24 +78,11 @@ module.exports = React.createClass({
cli.removeListener("RoomMember.name", this.onRoomMemberName); cli.removeListener("RoomMember.name", this.onRoomMemberName);
cli.removeListener("RoomState.events", this.onRoomStateEvent); cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom); cli.removeListener("Room", this.onRoom);
cli.removeListener("User.presence", this.onUserPresence); cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
// cli.removeListener("Room.timeline", this.onRoomTimeline); // cli.removeListener("Room.timeline", this.onRoomTimeline);
} }
}, },
componentDidMount: function() {
var self = this;
// Lazy-load in more than the first N members
setTimeout(function() {
if (!self.isMounted()) return;
// lazy load to prevent it blocking the first render
self.setState({
members: self.roomMembers()
});
}, 50);
},
/* /*
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
// ignore anything but real-time updates at the end of the room: // ignore anything but real-time updates at the end of the room:
@ -121,7 +111,7 @@ module.exports = React.createClass({
}, },
*/ */
onUserPresence(event, user) { onUserLastPresenceTs(event, user) {
// Attach a SINGLE listener for global presence changes then locate the // Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile // member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener. // evar attaching their own listener.
@ -325,7 +315,7 @@ module.exports = React.createClass({
return all_members; return all_members;
}, },
roomMembers: function(limit) { roomMembers: function() {
var all_members = this.memberDict || {}; var all_members = this.memberDict || {};
var all_user_ids = Object.keys(all_members); var all_user_ids = Object.keys(all_members);
var ConferenceHandler = CallHandler.getConferenceHandler(); var ConferenceHandler = CallHandler.getConferenceHandler();
@ -334,7 +324,7 @@ module.exports = React.createClass({
var to_display = []; var to_display = [];
var count = 0; var count = 0;
for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) { for (var i = 0; i < all_user_ids.length; ++i) {
var user_id = all_user_ids[i]; var user_id = all_user_ids[i];
var m = all_members[user_id]; var m = all_members[user_id];
@ -442,9 +432,16 @@ module.exports = React.createClass({
var memberList = self.state.members.filter(function(userId) { var memberList = self.state.members.filter(function(userId) {
var m = self.memberDict[userId]; var m = self.memberDict[userId];
if (query && m.name.toLowerCase().indexOf(query) === -1) {
return false; if (query) {
const matchesName = m.name.toLowerCase().indexOf(query) !== -1;
const matchesId = m.userId.toLowerCase().indexOf(query) !== -1;
if (!matchesName && !matchesId) {
return false;
}
} }
return m.membership == membership; return m.membership == membership;
}).map(function(userId) { }).map(function(userId) {
var m = self.memberDict[userId]; var m = self.memberDict[userId];

View file

@ -65,7 +65,12 @@ module.exports = React.createClass({
tags_changed: false, tags_changed: false,
tags: tags, tags: tags,
areNotifsMuted: areNotifsMuted, areNotifsMuted: areNotifsMuted,
isRoomPublished: false, // loaded async in componentWillMount // isRoomPublished is loaded async in componentWillMount so when the component
// inits, the saved value will always be undefined, however getInitialState()
// is also called from the saving code so we must return the correct value here
// if we have it (although this could race if the user saves before we load whether
// the room is published or not).
isRoomPublished: this._originalIsRoomPublished,
}; };
}, },
@ -211,10 +216,13 @@ module.exports = React.createClass({
// color scheme // color scheme
promises.push(this.saveColor()); promises.push(this.saveColor());
// url preview settings
promises.push(this.saveUrlPreviewSettings());
// encryption // encryption
promises.push(this.saveEncryption()); promises.push(this.saveEncryption());
console.log("Performing %s operations", promises.length); console.log("Performing %s operations: %s", promises.length, JSON.stringify(promises));
return q.allSettled(promises); return q.allSettled(promises);
}, },
@ -228,6 +236,11 @@ module.exports = React.createClass({
return this.refs.color_settings.saveSettings(); return this.refs.color_settings.saveSettings();
}, },
saveUrlPreviewSettings: function() {
if (!this.refs.url_preview_settings) { return q(); }
return this.refs.url_preview_settings.saveSettings();
},
saveEncryption: function () { saveEncryption: function () {
if (!this.refs.encrypt) { return q(); } if (!this.refs.encrypt) { return q(); }
@ -422,6 +435,7 @@ module.exports = React.createClass({
var AliasSettings = sdk.getComponent("room_settings.AliasSettings"); var AliasSettings = sdk.getComponent("room_settings.AliasSettings");
var ColorSettings = sdk.getComponent("room_settings.ColorSettings"); var ColorSettings = sdk.getComponent("room_settings.ColorSettings");
var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings");
var EditableText = sdk.getComponent('elements.EditableText'); var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector'); var PowerSelector = sdk.getComponent('elements.PowerSelector');
@ -654,6 +668,8 @@ module.exports = React.createClass({
canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')} canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')}
aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} /> aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} />
<UrlPreviewSettings ref="url_preview_settings" room={this.props.room} />
<h3>Permissions</h3> <h3>Permissions</h3>
<div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings"> <div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div className="mx_RoomSettings_powerLevel"> <div className="mx_RoomSettings_powerLevel">

View file

@ -43,7 +43,10 @@ module.exports = React.createClass({
}, },
getInitialState: function() { getInitialState: function() {
return( { hover : false }); return({
hover : false,
badgeHover : false,
});
}, },
onClick: function() { onClick: function() {
@ -61,6 +64,14 @@ module.exports = React.createClass({
this.setState( { hover : false }); this.setState( { hover : false });
}, },
badgeOnMouseEnter: function() {
this.setState( { badgeHover : true } );
},
badgeOnMouseLeave: function() {
this.setState( { badgeHover : false } );
},
render: function() { render: function() {
var myUserId = MatrixClientPeg.get().credentials.userId; var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId]; var me = this.props.room.currentState.members[myUserId];
@ -83,9 +94,25 @@ module.exports = React.createClass({
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
var badge; var badge;
if (this.props.highlight || notificationCount > 0) { var badgeContent;
badge = <div className="mx_RoomTile_badge">{ notificationCount ? notificationCount : '!' }</div>; var badgeClasses;
if (this.state.badgeHover) {
badgeContent = "\u00B7\u00B7\u00B7";
} else if (this.props.highlight || notificationCount > 0) {
badgeContent = notificationCount ? notificationCount : '!';
} else {
badgeContent = '\u200B';
} }
if (this.props.highlight || notificationCount > 0) {
badgeClasses = "mx_RoomTile_badge";
} else {
badgeClasses = "mx_RoomTile_badge mx_RoomTile_badge_no_unread";
}
badge = <div className={ badgeClasses } onMouseEnter={this.badgeOnMouseEnter} onMouseLeave={this.badgeOnMouseLeave}>{ badgeContent }</div>;
/* /*
if (this.props.highlight) { if (this.props.highlight) {
badge = <div className="mx_RoomTile_badge">!</div>; badge = <div className="mx_RoomTile_badge">!</div>;

View file

@ -24,17 +24,17 @@ module.exports = React.createClass({
displayName: 'TabCompleteBar', displayName: 'TabCompleteBar',
propTypes: { propTypes: {
entries: React.PropTypes.array.isRequired tabComplete: React.PropTypes.object.isRequired
}, },
render: function() { render: function() {
return ( return (
<div className="mx_TabCompleteBar"> <div className="mx_TabCompleteBar">
{this.props.entries.map(function(entry, i) { {this.props.tabComplete.peek(6).map((entry, i) => {
return ( return (
<div key={entry.getKey() || i + ""} <div key={entry.getKey() || i + ""}
className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") } className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") }
onClick={entry.onClick.bind(entry)} > onClick={this.props.tabComplete.onEntryClick.bind(this.props.tabComplete, entry)} >
{entry.getImageJsx()} {entry.getImageJsx()}
<span className="mx_TabCompleteBar_text"> <span className="mx_TabCompleteBar_text">
{entry.getText()} {entry.getText()}

View file

@ -64,11 +64,15 @@ function createRoom(opts) {
} }
]; ];
var modal = Modal.createDialog(Loader); var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
return client.createRoom(createOpts).finally(function() { return client.createRoom(createOpts).finally(function() {
modal.close(); modal.close();
}).then(function(res) { }).then(function(res) {
// NB createRoom doesn't block on the client seeing the echo that the
// room has been created, so we race here with the client knowing that
// the room exists, causing things like
// https://github.com/vector-im/vector-web/issues/1813
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: res.room_id room_id: res.room_id

View file

@ -210,7 +210,7 @@ describe('TimelinePanel', function() {
var N_EVENTS = 600; var N_EVENTS = 600;
// sadly, loading all those events takes a while // sadly, loading all those events takes a while
this.timeout(N_EVENTS * 20); this.timeout(N_EVENTS * 30);
// client.getRoom is called a /lot/ in this test, so replace // client.getRoom is called a /lot/ in this test, so replace
// sinon's spy with a fast noop. // sinon's spy with a fast noop.
@ -271,6 +271,8 @@ describe('TimelinePanel', function() {
// we should now be able to scroll down, and paginate in the other // we should now be able to scroll down, and paginate in the other
// direction. // direction.
console.log("scrollingDiv.scrollTop is " + scrollingDiv.scrollTop);
console.log("Going to set it to " + scrollingDiv.scrollHeight);
scrollingDiv.scrollTop = scrollingDiv.scrollHeight; scrollingDiv.scrollTop = scrollingDiv.scrollHeight;
return awaitScroll(); return awaitScroll();
}).then(() => { }).then(() => {