Compare commits

...

14 Commits

@ -0,0 +1,78 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"plugins": [
"react"
],
"rules": {
/* Things that should effectively be syntax errors. */
"indent": [ "error", "tab", {
SwitchCase: 1
}],
"linebreak-style": [ "error", "unix" ],
"semi": [ "error", "always" ],
/* Things that are always mistakes. */
"getter-return": [ "error" ],
"no-compare-neg-zero": [ "error" ],
"no-dupe-args": [ "error" ],
"no-dupe-keys": [ "error" ],
"no-duplicate-case": [ "error" ],
"no-empty": [ "error" ],
"no-empty-character-class": [ "error" ],
"no-ex-assign": [ "error" ],
"no-extra-semi": [ "error" ],
"no-func-assign": [ "error" ],
"no-invalid-regexp": [ "error" ],
"no-irregular-whitespace": [ "error" ],
"no-obj-calls": [ "error" ],
"no-sparse-arrays": [ "error" ],
"no-undef": [ "error" ],
"no-unreachable": [ "error" ],
"no-unsafe-finally": [ "error" ],
"use-isnan": [ "error" ],
"valid-typeof": [ "error" ],
"curly": [ "error" ],
"no-caller": [ "error" ],
"no-fallthrough": [ "error" ],
"no-extra-bind": [ "error" ],
"no-extra-label": [ "error" ],
"array-callback-return": [ "error" ],
"prefer-promise-reject-errors": [ "error" ],
"no-with": [ "error" ],
"no-useless-concat": [ "error" ],
"no-unused-labels": [ "error" ],
"no-unused-expressions": [ "error" ],
"no-unused-vars": [ "error" , { argsIgnorePattern: "^_" } ],
"no-return-assign": [ "error" ],
"no-self-assign": [ "error" ],
"no-new-wrappers": [ "error" ],
"no-redeclare": [ "error" ],
"no-loop-func": [ "error" ],
"no-implicit-globals": [ "error" ],
"strict": [ "error", "global" ],
/* Make JSX not cause 'unused variable' errors. */
"react/jsx-uses-react": ["error"],
"react/jsx-uses-vars": ["error"],
/* Development code that should be removed before deployment. */
"no-console": [ "warn" ],
"no-constant-condition": [ "warn" ],
"no-debugger": [ "warn" ],
"no-alert": [ "warn" ],
"no-warning-comments": ["warn", {
terms: ["fixme"]
}],
/* Common mistakes that can *occasionally* be intentional. */
"no-template-curly-in-string": ["warn"],
"no-unsafe-negation": [ "warn" ],
}
};

@ -0,0 +1,28 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch debugging in Firefox",
"type": "firefox",
"request": "launch",
"reAttach": true,
"url": "http://localhost:3000/",
"webRoot": "${workspaceFolder}"
},
{
"name": "Attach",
"type": "firefox",
"request": "attach"
},
{
"name": "Launch WebExtension",
"type": "firefox",
"request": "launch",
"reAttach": true,
"addonPath": "${workspaceFolder}"
}
]
}

@ -1,88 +1,88 @@
const gulp = require('gulp') "use strict";
const sass = require('gulp-sass')
const concat = require('gulp-concat')
const gutil = require('gulp-util')
const imagemin = require('gulp-imagemin')
const cache = require('gulp-cache')
const gulpIf = require('gulp-if')
const browserify = require('browserify')
const del = require('del')
const source = require('vinyl-source-stream') const gulp = require('gulp');
const buffer = require('vinyl-buffer') const sass = require('gulp-sass');
const sourcemaps = require('gulp-sourcemaps') const concat = require('gulp-concat');
const imagemin = require('gulp-imagemin');
const cache = require('gulp-cache');
const gulpIf = require('gulp-if');
const browserify = require('browserify');
const del = require('del');
const budo = require('budo') const source = require('vinyl-source-stream');
const babelify = require('babelify') const buffer = require('vinyl-buffer');
const sourcemaps = require('gulp-sourcemaps');
const cssFiles = 'src/scss/**/*.?(s)css' const budo = require('budo');
const babelify = require('babelify');
let css = gulp.src(cssFiles) const cssFiles = 'src/scss/**/*.?(s)css';
.pipe(sass()) const assetsFiles = [ "src/assets/**/*" ];
.pipe(concat('style.css'))
.pipe(gulp.dest('build'))
gulp.task('watch', function(cb) {
budo("src/app.js", {
live: true,
dir: "build",
port: 3000,
browserify: {
transform: babelify
}
}).on('exit', cb)
gulp.watch(cssFiles, gulp.series(["sass"]))
})
gulp.task("clean", function(done) { gulp.task("clean", function(done) {
del.sync('build') del.sync('build');
done() done();
}) });
gulp.task("sass", function() { gulp.task("sass", function() {
return gulp.src(cssFiles) return gulp.src(cssFiles)
.pipe(sass()) .pipe(sass())
.pipe(concat('style.css')) .pipe(concat('style.css'))
.pipe(gulp.dest('./build')) .pipe(gulp.dest('./build'));
}) });
gulp.task("assets", function() { gulp.task("assets", function() {
return gulp.src(["src/assets/**/*"]) return gulp.src(assetsFiles)
.pipe(gulpIf('*.+(png|jpg|jpeg|gif|svg)', /* NOTE: Currently disabled, causes an error:
cache(imagemin({ [19:47:33] Error: Callback called multiple times
interlaced: true at DestroyableTransform.afterTransform (/home/sven/projects/iris/node_modules/gulp-cache/node_modules/readable-stream/lib/_stream_transform.js:82:31)
})) at EventEmitter.signals.on.err (/home/sven/projects/iris/node_modules/gulp-cache/lib/index.js:451:7)
)) at emitOne (events.js:116:13)
.pipe(gulp.dest('build')) at EventEmitter.emit (events.js:211:7)
}) at DestroyableTransform.onError (/home/sven/projects/iris/node_modules/gulp-cache/lib/index.js:288:15)
at Object.onceWrapper (events.js:315:30)
at emitOne (events.js:116:13)
at DestroyableTransform.emit (events.js:211:7)
at /home/sven/projects/iris/node_modules/through2-concurrent/through2-concurrent.js:41:14
at imagemin.buffer.then.catch.error (/home/sven/projects/iris/node_modules/gulp-imagemin/index.js:98:5)
*/
// .pipe(gulpIf('*.+(png|jpg|jpeg|gif|svg)',
// cache(imagemin({
// interlaced: true
// }))
// ))
.pipe(gulp.dest('build'));
});
gulp.task('js', function() { gulp.task('js', function() {
return gulp.src(['src/app.js', "src/components/**/*"]) let b = browserify({
.pipe(babel({ entries: 'src/app.js',
presets: [ debug: false,
['@babel/env', { transform: [babelify.configure({
modules: false presets: ['@babel/preset-env', '@babel/preset-react']
}] })]
] });
})) return b.bundle()
.pipe(gulp.dest('build')) .pipe(source('src/app.js'))
}) .pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
.pipe(gulp.dest('build'));
});
gulp.task('js', function() { gulp.task('watch', gulp.series(["clean", "assets", "sass", function(cb) {
let b = browserify({ budo("src/app.js", {
entries: 'src/app.js', live: true,
debug: false, dir: "build",
transform: [babelify.configure({ port: 3000,
presets: ['@babel/preset-env', '@babel/preset-react'] browserify: {
})] transform: babelify
}) }
return b.bundle() }).on('exit', cb);
.pipe(source('src/app.js'))
.pipe(buffer()) gulp.watch(cssFiles, gulp.series(["sass"]));
.pipe(sourcemaps.init({ loadMaps: true })) gulp.watch(assetsFiles, gulp.series(["assets"]));
.pipe(gulp.dest('build')) }]));
})
gulp.task('build', gulp.parallel(['clean', 'assets', 'js', 'sass', function(done) { gulp.task('build', gulp.parallel(['clean', 'assets', 'js', 'sass', function(done) {
done() done();
}])) }]));

@ -19,6 +19,7 @@
"budo": "^11.5.0", "budo": "^11.5.0",
"color-convert": "^2.0.0", "color-convert": "^2.0.0",
"create-react-class": "^15.6.3", "create-react-class": "^15.6.3",
"dataprog": "^0.1.0",
"debounce": "^1.2.0", "debounce": "^1.2.0",
"default-value": "^1.0.0", "default-value": "^1.0.0",
"del": "^3.0.0", "del": "^3.0.0",
@ -40,13 +41,15 @@
"react-dom": "^16.6.3", "react-dom": "^16.6.3",
"sanitize-html": "^1.20.0", "sanitize-html": "^1.20.0",
"sourceify": "^0.1.0", "sourceify": "^0.1.0",
"validatem": "^0.2.0",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0", "vinyl-source-stream": "^2.0.0",
"webpack": "^4.27.1" "webpack": "^4.27.1"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^6.0.1",
"eslint-plugin-react": "^7.14.2",
"gulp-watch": "^5.0.1", "gulp-watch": "^5.0.1",
"mocha-reporter-remote": "^1.7.1",
"livereactload": "^4.0.0-beta.2", "livereactload": "^4.0.0-beta.2",
"mocha": "^6.1.4" "mocha": "^6.1.4"
} }

@ -1,14 +1,12 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const ReactDOM = require('react-dom');
const create = require('create-react-class') const create = require('create-react-class');
const Promise = require('bluebird') const sdk = require('matrix-js-sdk');
const urllib = require('url')
const sdk = require('matrix-js-sdk')
const Sidebar = require('./components/sidebar.js') const Sidebar = require('./components/sidebar.js');
const Login = require('./components/Login.js') const Login = require('./components/Login.js');
const Chat = require('./components/chat.js') const Chat = require('./components/chat.js');
// Things that will get settings: // Things that will get settings:
// colorscheme // colorscheme
@ -16,86 +14,95 @@ const Chat = require('./components/chat.js')
// incoming/outgoing message alignment (split) // incoming/outgoing message alignment (split)
let App = create({ let App = create({
displayName: "App", displayName: "App",
getInitialState: function() { getInitialState: function() {
return { return {
rooms: [], rooms: [],
options: { options: {
fallbackMediaRepos: [] fallbackMediaRepos: []
} }
} };
}, },
componentDidMount: function() { componentDidMount: function() {
//check if accessToken is stored in localStorage //check if accessToken is stored in localStorage
let accessToken = localStorage.getItem('accessToken') let accessToken = localStorage.getItem('accessToken');
if (localStorage.accessToken != undefined) { if (localStorage.accessToken != undefined) {
let userId = localStorage.getItem('userId') let userId = localStorage.getItem('userId');
let apiUrl = localStorage.getItem('apiUrl') let apiUrl = localStorage.getItem('apiUrl');
this.loginCallback(userId, accessToken, apiUrl, true) this.loginCallback(userId, accessToken, apiUrl, true);
} }
}, },
loginCallback: function(userId, accessToken, apiUrl, restored) { loginCallback: function(userId, accessToken, apiUrl, restored) {
if (restored) { if (restored) {
console.log("Restoring from localStorage") console.log("Restoring from localStorage");
} else { } else {
userId = '@' + userId.replace('@', '') userId = '@' + userId.replace('@', '');
localStorage.setItem('userId', userId) localStorage.setItem('userId', userId);
localStorage.setItem('accessToken', accessToken) localStorage.setItem('accessToken', accessToken);
localStorage.setItem('apiUrl', apiUrl) localStorage.setItem('apiUrl', apiUrl);
} }
let client = sdk.createClient({ let client = sdk.createClient({
baseUrl: apiUrl, baseUrl: apiUrl,
accessToken: accessToken, accessToken: accessToken,
userId: userId userId: userId
}); });
this.setState({ this.setState({
client: client client: client
}) });
this.startClient(client)
},
startClient: function(client) { this.startClient(client);
console.log(client) },
client.on("sync", (state, prevState, data) => {
if (state == "ERROR") { updateRooms: function (client) {
} else if (state == "SYNCING") { let rooms = {};
let rooms = {}
client.getRooms().forEach((room) => {
rooms[room.roomId] = room
})
this.setState({rooms: rooms})
} else if (state == "PREPARED") {
}
})
client.on("Room.localEchoUpdated", (event) => {
let rooms = {}
client.getRooms().forEach((room) => {
rooms[room.roomId] = room
})
this.setState({rooms: rooms})
})
client.startClient()
},
render: function() { client.getRooms().forEach((room) => {
if (this.state.client == undefined) { rooms[room.roomId] = room;
//Login screen });
return <Login callback={this.loginCallback}/>
} this.setState({rooms: rooms});
return ( },
<>
<Sidebar options={this.state.options} client={this.state.client} rooms={this.state.rooms} selectRoom={(roomId) => {this.setState({roomId: roomId})}}/> startClient: function(client) {
<Chat client={this.state.client} roomId={this.state.roomId}/> console.log(client);
</>
) client.on("sync", (state, _prevState, _data) => {
} if (state == "ERROR") {
}) /* FIXME: Implement? */
} else if (state == "SYNCING") {
this.updateRooms(client);
} else if (state == "PREPARED") {
/* FIXME: Implement? */
}
});
client.on("Room.localEchoUpdated", (_event) => {
this.updateRooms(client);
});
client.startClient();
},
render: function() {
if (this.state.client == undefined) {
//Login screen
return <Login callback={this.loginCallback}/>;
} else {
return (
<>
<Sidebar options={this.state.options} client={this.state.client} rooms={this.state.rooms} selectRoom={(roomId) => {this.setState({roomId: roomId});}}/>
<Chat client={this.state.client} roomId={this.state.roomId}/>
</>
);
}
}
});
ReactDOM.render( ReactDOM.render(
<App />, <App />,
document.getElementById('root') document.getElementById('root')
) );

@ -1,228 +1,260 @@
'use strict' 'use strict';
const React = require('react')
const ReactDOM = require('react-dom') const React = require('react');
const create = require('create-react-class') const create = require('create-react-class');
const Promise = require('bluebird') const Promise = require('bluebird');
const urllib = require('url') const urllib = require('url');
const debounce = require('debounce')
const defaultValue = require('default-value') const createApiRequester = require("../lib/api-request");
let login = create({ let login = create({
displayName: "Login", displayName: "Login",
getInitialState: function() { getInitialState: function() {
return { return {
error: null, error: null,
formState: { formState: {
user: "", user: "",
pass: "", pass: "",
hs: "" hs: ""
}, },
hs: { hs: {
prompt: false, prompt: false,
error: null, error: null,
valid: false valid: false
} }
} };
}, },
login: function() { login: function() {
this.setState({error: ""}) return Promise.try(() => {
this.setState({error: ""});
if (this.state.hs.valid) {
return this.doLogin() if (this.state.hs.valid) {
} return this.doLogin();
}
let parts = this.state.formState.user.split(':')
if (parts.length != 2) { let parts = this.state.formState.user.split(':');
return this.setState({error: "Please enter a full mxid, like username:homeserver.tld"}) if (parts.length != 2) {
} return this.setState({error: "Please enter a full mxid, like username:homeserver.tld"});
}
let hostname = urllib.parse("https://" + parts[1])
getApiServer(hostname).then((hs) => { let hostname = urllib.parse("https://" + parts[1]);
console.log("Using API server", hs)
let formState = this.state.formState return Promise.try(() => {
formState.user = parts[0] return getApiServer(hostname);
formState.hs = hs }).then((homeserverUrl) => {
let hsState = Object.assign(this.state.hs, {valid: true}) console.log("Using API server", homeserverUrl);
this.setState({apiUrl: hs, formState: formState, hs: hsState})
this.doLogin() this.setState({
}).catch((error) => { apiUrl: homeserverUrl,
console.log("ERROR fetching homeserver url", error) apiRequest: createApiRequester(homeserverUrl),
let hsState = Object.assign(this.state.hs, {error: error, valid: false, prompt: true}) formState: Object.assign(this.state.formState, {
this.setState({hs: hsState}) user: parts[0],
}) hs: homeserverUrl
}, }),
hs: Object.assign(this.state.hs, {
doLogin: function() { valid: true
console.log("Logging in") })
let user = this.state.formState.user.replace('@', '') });
let password = this.state.formState.pass
let hs = this.state.apiUrl return this.doLogin();
}).catch((error) => {
let data = { /* FIXME: Error filtering */
user: user, console.log("ERROR fetching homeserver url", error);
password: password,
type: "m.login.password", this.setState({
initial_device_display_name: "Neo v4", hs: Object.assign(this.state.hs, {
}; error: error,
valid: false,
let url = hs + "/_matrix/client/r0/login" prompt: true
})
fetch(url, { });
body: JSON.stringify(data), });
headers: { });
'content-type': 'application/json' },
},
method: 'POST', doLogin: function() {
}).then((response) => response.json()) return Promise.try(() => {
.then((responseJson) => { console.log("Logging in");
console.log("got access token", responseJson) let user = this.state.formState.user.replace('@', '');
this.setState({json: responseJson}) let password = this.state.formState.pass;
if(responseJson.access_token != undefined) { let homeserverUrl = this.state.apiUrl;
this.props.callback(responseJson.user_id, responseJson.access_token, hs)
} else { return Promise.try(() => {
this.setState({error: responseJson.error}) return this.state.apiRequest("/_matrix/client/r0/login", {
} user: user,
}) password: password,
.catch((error) => { type: "m.login.password",
console.error(url, error); initial_device_display_name: "Neo v4",
}); });
}, }).then((responseJson) => {
console.log("got access token", responseJson);
handleUserChange: function(e) {
let formState = this.state.formState this.setState({ json: responseJson });
let user = e.target.value
formState.user = e.target.value if(responseJson.access_token != undefined) {
let parts = user.split(':') this.props.callback(responseJson.user_id, responseJson.access_token, homeserverUrl);
if (parts.length == 2) { } else {
formState.hs = parts[1] this.setState({ error: responseJson.error });
let hsState = Object.assign(this.state.hs, {error: null, valid: false}) }
this.setState({hs: hsState}) }).catch((error) => {
} /* FIXME: Why are errors being swallowed here? */
this.setState({formState: formState}) console.error(error);
}, });
});
handlePassChange: function(e) { },
let formState = this.state.formState
formState.pass = e.target.value handleUserChange: function(e) {
this.setState({formState: formState}) let formState = this.state.formState;
}, let user = e.target.value;
formState.user = e.target.value;
handleHsChange: function(e) { let parts = user.split(':');
let formState = this.state.formState if (parts.length == 2) {
formState.hs = e.target.value formState.hs = parts[1];
this.setState({formState: formState}) let hsState = Object.assign(this.state.hs, {error: null, valid: false});
this.setState({hs: {error: null, valid: false, prompt: true, changed: true}}) this.setState({hs: hsState});
}, }
this.setState({formState: formState});
render: function() { },
let hsState = "inactive"
if (this.state.hs.prompt) { handlePassChange: function(e) {
hsState = "active" let formState = this.state.formState;
} formState.pass = e.target.value;
if (this.state.hs.error != null) { this.setState({formState: formState});
hsState = "error" },
}
if (this.state.hs.valid) { handleHomeserverChange: function(e) {
hsState = "validated" let formState = this.state.formState;
} formState.hs = e.target.value;
this.setState({formState: formState});
return ( this.setState({hs: {error: null, valid: false, prompt: true, changed: true}});
<div className="loginwrapper"> },
<img src="./neo.png"/>
<div className="errorMessage">{this.state.error}</div> render: function() {
<div className="login"> let hsState = "inactive";
<label htmlFor="user">Username: </label> if (this.state.hs.prompt) {
<input type="text" id="user" placeholder="@user:homeserver.tld" value={this.state.formState["user"]} onChange={this.handleUserChange}/> hsState = "active";
}
<label htmlFor="pass">Password: </label> if (this.state.hs.error != null) {
<input type="password" id="pass" placeholder="password" value={this.state.formState["pass"]} onChange={this.handlePassChange}/> hsState = "error";
}
<label htmlFor="hs" className={hsState}>Homeserver: </label> if (this.state.hs.valid) {
{this.state.hs.prompt ? ( hsState = "validated";
<> }
<input type="text" id="hs" value={this.state.formState["hs"]} onChange={this.handleHsChange}/>
</> return (
) : ( <div className="loginwrapper">
<span id="hs">{this.state.formState["hs"]}</span> <img src="./neo.png"/>
)} <div className="errorMessage">{this.state.error}</div>
<div className="login">
<button onClick={()=>this.login()}>Log in</button> <label htmlFor="user">Username: </label>
</div> <input type="text" id="user" placeholder="@user:homeserver.tld" value={this.state.formState["user"]} onChange={this.handleUserChange}/>
</div>
) <label htmlFor="pass">Password: </label>
} <input type="password" id="pass" placeholder="password" value={this.state.formState["pass"]} onChange={this.handlePassChange}/>
})
<label htmlFor="hs" className={hsState}>Homeserver: </label>
function getApiServer(hostname) { {this.state.hs.prompt ? (
return new Promise((resolve, reject) => { <>
console.log("Checking for api server from mxid", urllib.format(hostname)) <input type="text" id="hs" value={this.state.formState["hs"]} onChange={this.handleHomeserverChange}/>
checkApi(hostname).then(() => { </>
// Hostname is a valid api server ) : (
hostname.pathname = "" <span id="hs">{this.state.formState["hs"]}</span>
resolve(urllib.format(hostname)) )}
}).catch(() => {
console.log("trying .well-known") <button onClick={()=>this.login()}>Log in</button>
tryWellKnown(hostname).then((hostname) => { </div>
console.log("got .well-known host", hostname) </div>
resolve(hostname) );
}).catch((err) => { }
reject("Fatal error trying to get API host") });
})
}) function getApiServer(parsedUrl) {
}) return Promise.try(() => {
console.log("Checking for api server from mxid", urllib.format(parsedUrl));
return checkApi(parsedUrl);
}).then(() => {
// Hostname is a valid api server
return buildUrl(parsedUrl, "");
}).catch(() => {
/* FIXME: Error filtering */
console.log("trying .well-known");
return Promise.try(() => {
return tryWellKnown(parsedUrl);
}).then((hostname) => {
console.log("got .well-known host", hostname);
return hostname;
}).catch((_err) => {
/* FIXME: Error chaining */
throw new Error("Fatal error trying to get API host");
});
});
} }
function checkApi(host) { function checkApi(host) {
let versionUrl = buildUrl(host, "/_matrix/client/versions") return Promise.try(() => {
return new Promise((resolve, reject) => { let versionUrl = buildUrl(host, "/_matrix/client/versions");
fetch(versionUrl).then((response) => {
if (response.status != 200) { return Promise.try(() => {
console.log("Invalid homeserver url", versionUrl) return fetch(versionUrl);
return reject() }).then((response) => {
} if (response.status != 200) {
resolve() console.log("Invalid homeserver url", versionUrl);
}).catch((err) => {
reject(err) /* FIXME: Error types */
}) throw new Error("Invalid homeserver URL");
}) }
});
});
} }
function tryWellKnown(host) { function tryWellKnown(host) {
let wellKnownUrl = urllib.format(Object.assign(host, { let wellKnownUrl = urllib.format(Object.assign(host, {
pathname: "/.well-known/matrix/client" pathname: "/.well-known/matrix/client"
})) }));
console.log("Trying", wellKnownUrl, "for .well-known")
return new Promise((resolve, reject) => { console.log("Trying", wellKnownUrl, "for .well-known");
return fetch(wellKnownUrl)
.then((response) => { return Promise.try(() => {
if (response.status != 200) { return fetch(wellKnownUrl);
console.log("no well-known in use") }).tap((response) => {
reject("No homeserver found") if (response.status != 200) {
} console.log("no well-known in use");
return response
}).catch((error) => { /* FIXME: Error type */
reject("can't fetch .well-known") throw new Error("No homeserver found");
}) }
.then((response) => response.json()) }).catch((_error) => {
.then((json) => { /* FIXME: Error chaining */
console.log("Parsed json", json) throw new Error("can't fetch .well-known");
if (json['m.homeserver'] != undefined && json['m.homeserver'].base_url != undefined) { }).then((response) => {
resolve(json['m.homeserver'].base_url) return response.json();
} }).then((json) => {
}) console.log("Parsed json", json);
.catch((err) => {
console.log("Error in json", err) if (json['m.homeserver'] != null && json['m.homeserver'].base_url != null) {
reject("Error while parsing .well-known") return json['m.homeserver'].base_url;
}) } else {
}) /* FIXME: Error type */
throw new Error("No homeserver specified in .well-known");
}
}).catch((err) => {
/* FIXME: Error filtering? */
console.log("Error in json", err);
/* FIXME: Error chaining */
throw new Error("Error while parsing .well-known");
});
} }
function buildUrl(host, path) { function buildUrl(parsedUrl, path) {
return urllib.format(Object.assign(host, { return urllib.format(Object.assign(parsedUrl, {
pathname: path pathname: path
})) }));
} }
module.exports = login module.exports = login;

@ -1,227 +1,201 @@
'use strict' "use strict";
const React = require('react')
const ReactDOM = require('react-dom') const Promise = require("bluebird");
const create = require('create-react-class') const React = require("react");
const Promise = require('bluebird') const create = require("create-react-class");
const debounce = require('debounce') const sanitize = require("sanitize-html");
const jdenticon = require('jdenticon') const { expression } = require("dataprog");
const defaultValue = require('default-value')
const sdk = require('matrix-js-sdk') const Event = require("./events/Event.js");
const sanitize = require('sanitize-html') const Info = require("./info.js");
const Input = require("./input.js");
const Event = require('./events/Event.js') const User = require("./events/user.js");
const Info = require('./info.js') const Loading = require("./loading.js");
const Input = require('./input.js')
const User = require('./events/user.js') const generateJdenticon = require("../lib/generate-jdenticon");
const Loading = require('./loading.js') const generateThumbnailUrl = require("../lib/generate-thumbnail-url");
const groupEvents = require("../lib/group-events");
jdenticon.config = { const parseMXC = require("../lib/parse-mxc");
lightness: { const withElement = require("../lib/with-element");
color: [0.58, 0.66],
grayscale: [0.30, 0.90]
},
saturation: {
color: 0.66,
grayscale: 0.00
},
backColor: "#00000000"
};
let eventFunctions = { let eventFunctions = {
plaintext: function() { plaintext: function() {
let plain = "unknown event" if (this.type == "m.room.message") {
if (this.content.format == "org.matrix.custom.html") {
if (this.type == "m.room.message") { return sanitize(this.content.formatted_body, {allowedTags: []});
plain = this.content.body } else {
return this.content.body;
if (this.content.format == "org.matrix.custom.html") { }
plain = sanitize(this.content.formatted_body, {allowedTags: []}) } else if (this.type == "m.room.member") {
} if (this.content.membership == "invite") {
} return `${this.sender} invited ${this.state_key}`;
if (this.type == "m.room.member") { } else if (this.content.membership == "join") {
if (this.content.membership == "invite") { return `${this.state_key} joined the room`;
plain = `${this.sender} invited ${this.state_key}` } else if (this.content.membership == "leave") {
} else if (this.content.membership == "join") { return `${this.state_key} left the room`;
plain = `${this.state_key} joined the room` } else if (this.content.membership == "kick") {
} else if (this.content.membership == "leave") { return `${this.sender} kicked ${this.state_key}`;
plain = `${this.state_key} left the room` } else if (this.content.membership == "ban") {
} else if (this.content.membership == "kick") { return `${this.sender} banned ${this.state_key}`;
plain = `${this.sender} kicked ${this.state_key}` } else {
} else if (this.content.membership == "ban") { return "unknown member event";
plain = `${this.sender} banned ${this.state_key}` }
} } else if (this.type == "m.room.avatar") {
} if (this.content.url.length > 0) {
if (this.type == "m.room.avatar") { return `${this.sender} changed the room avatar`;
if (this.content.url.length > 0) { }
plain = `${this.sender} changed the room avatar` } else if (this.type == "m.room.name") {
} return `${this.sender} changed the room name to ${this.content.name}`;
} } else {
if (this.type == "m.room.name") { return "unknown event";
return `${this.sender} changed the room name to ${this.content.name}` }
} }
return plain };
}
}
let chat = create({ let chat = create({
displayName: "Chat", displayName: "Chat",
getInitialState: function() { getInitialState: function() {
return { return {
ref: null, ref: null,
loading: false loading: false
} };
}, },
getSnapshotBeforeUpdate: function(oldProps, oldState) { getSnapshotBeforeUpdate: function(_oldProps, _oldState) {
let ref = this.state.ref let ref = this.state.ref;
if (ref == null) {return null}
if ((ref.scrollHeight - ref.offsetHeight) - ref.scrollTop < 100) { // Less than 100px from bottom if (ref != null && (ref.scrollHeight - ref.offsetHeight) - ref.scrollTop < 100) {
return true // Less than 100px from bottom
} return true;
return null } else {
}, return null;
}
componentDidUpdate(prevProps, prevState, snapshot) { },
let ref = this.state.ref
if (ref == null) {return} componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot) { // scroll to bottom let ref = this.state.ref;
ref.scrollTop = (ref.scrollHeight - ref.offsetHeight)
} if (ref != null && snapshot) {
}, // scroll to bottom
ref.scrollTop = (ref.scrollHeight - ref.offsetHeight);
setRef: function(ref) { }
if (ref != null) { },
this.setState({ref: ref})
} setRef: function(ref) {
}, if (ref != null) {
this.setState({ ref: ref });
onReplyClick: function(e) { }
this.setState({replyEvent: e}) },
},
onReplyClick: function(e) {
paginateBackwards: function() { this.setState({ replyEvent: e });
if (this.state.loading) { },
return
} paginateBackwards: function() {
let client = this.props.client if (!this.state.loading) {
client.paginateEventTimeline(client.getRoom(this.props.roomId).getLiveTimeline(), {backwards: true}).then(() => { let client = this.props.client;
this.setState({loading: false}) let timeline = client.getRoom(this.props.roomId).getLiveTimeline();
})
this.setState({loading: true}) this.setState({loading: true});
},
return Promise.try(() => {
render: function() { return client.paginateEventTimeline(timeline, {backwards: true});
let client = this.props.client }).then(() => {
let empty = ( this.setState({loading: false});
<div className="main"> });
</div> }
) },
if (this.props.roomId == undefined) {
//empty screen render: function() {
return empty let client = this.props.client;
}
let empty = <div className="main" />;
let room = client.getRoom(this.props.roomId)
if (room == null) { if (this.props.roomId == null) {
return empty //empty screen
} return empty;
} else {
let messageGroups = { let room = client.getRoom(this.props.roomId);
current: [], if (room == null) {
groups: [], return empty;
sender: "", } else {
type: "" let liveTimeline = room.getLiveTimeline();
} let liveTimelineEvents = liveTimeline.getEvents();
// if the sender is the same, add it to the 'current' messageGroup, if not, let events = liveTimelineEvents.map((item) => {
// push the old one to 'groups' and start with a new array. let event = item.event;
let liveTimeline = room.getLiveTimeline() return Object.assign(
let liveTimelineEvents = liveTimeline.getEvents() event,
eventFunctions,
let events = [] (event.sender == null)
if (liveTimelineEvents.length > 0) { /* Whether this event is a local echo */
liveTimelineEvents.forEach((MatrixEvent) => { ? { local: true, sender: event.user_id }
let event = MatrixEvent.event; : null
event = Object.assign(event, eventFunctions) );
if (event.sender == null) { // localecho messages });
event.sender = event.user_id
event.local = true let eventGroups = groupEvents(events);
}
if (event.sender != messageGroups.sender || event.type != messageGroups.type) { //TODO: replace with something that only renders events in view
messageGroups.sender = event.sender return (
messageGroups.type = event.type <div className="main">
if (messageGroups.current.length != 0) { <Info room={room} />
messageGroups.groups.push(messageGroups.current) <div className="chat" ref={this.setRef}>
} <div className="events">
messageGroups.current = [] <div className="paginateBackwards" onClick={this.paginateBackwards}>
} {this.state.loading ?
messageGroups.current.push(event) <Loading/> :
}) <span>load older messages</span>
messageGroups.groups.push(messageGroups.current) }
</div>
events = messageGroups.groups.map((events, id) => { {(eventGroups.map((group) => {
return <EventGroup key={`${this.props.roomId}-${events[0].event_id}`} events={events} client={this.props.client} room={room} onReplyClick={this.onReplyClick}/> return <EventGroup key={`${this.props.roomId}-${group.events[0].event_id}`} events={group.events} client={this.props.client} room={room} onReplyClick={this.onReplyClick}/>;
}) }))}
} </div>
//TODO: replace with something that only renders events in view </div>
return ( <Input client={client} roomId={this.props.roomId} replyEvent={this.state.replyEvent} onReplyClick={this.onReplyClick}/>
<div className="main"> </div>
<Info room={room} /> );
<div className="chat" ref={this.setRef}> }
<div className="events"> }
<div className="paginateBackwards" onClick={this.paginateBackwards}> }
{this.state.loading ? });
<Loading/> :
<span>load older messages</span> function EventGroup({ events, room, client, onReplyClick }) {
} let setAvatarRef = withElement((element) => {
</div> generateJdenticon(user.userId).update(element);
{events} });
</div>
</div> let user = client.getUser(events[0].sender);
<Input client={client} roomId={this.props.roomId} replyEvent={this.state.replyEvent} onReplyClick={this.onReplyClick}/>
</div> let avatar = expression(() => {
) if (user.avatarUrl != null) {
} let url = generateThumbnailUrl({
}) homeserver: client.baseUrl,
mxc: parseMXC(user.avatarUrl),
let EventGroup = create({ width: 128,
displayName: "EventGroup", height: 128
});
getInitialState: function() {
let user = this.props.client.getUser(this.props.events[0].sender) return <img id="avatar" src={url} />;
let avatar = <svg id="avatar" ref={this.avatarRef} /> } else {
return <svg id="avatar" ref={setAvatarRef} />;
if (user.avatarUrl != null) { }
let hs = this.props.client.baseUrl });
let media_mxc = user.avatarUrl.slice(6)
let url = `${hs}/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale` return (
avatar = <img id="avatar" src={url}/> <div className="eventGroup">
} {avatar}
<div className="col">
return { <User user={user}/>
user: user, {events.map((event, key) => {
avatar: avatar return <Event event={event} key={key} client={client} room={room} onReplyClick={onReplyClick}/>;
} })}
}, </div>
</div>
avatarRef: function(ref) { );
jdenticon.update(ref, this.state.user.userId) }
},
module.exports = chat;
render: function() {
let events = this.props.events.map((event, key) => {
return <Event event={event} key={key} client={this.props.client} room={this.props.room} onReplyClick={this.props.onReplyClick}/>
})
return <div className="eventGroup">
{this.state.avatar}
<div className="col">
<User user={this.state.user}/>
{events}
</div>
</div>
}
})
module.exports = chat

@ -1,115 +1,114 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class') const defaultValue = require('default-value');
const defaultValue = require('default-value')
const riot = require('../../lib/riot-utils.js') const riot = require('../../lib/riot-utils.js');
const User = require('./user.js') const User = require('./user.js');
const stateElement = require('./state.js') const stateElement = require('./state.js');
const elements = { const elements = {
"m.text": require('./text.js'), "m.text": require('./text.js'),
"m.image": require('./image.js'), "m.image": require('./image.js'),
"m.video": require('./video.js') "m.video": require('./video.js')
} };
const mxReplyRegex = /^<mx-reply>[\s\S]+<\/mx-reply>/ const mxReplyRegex = /^<mx-reply>[\s\S]+<\/mx-reply>/;
let Event = create({ let Event = create({
displayName: "Event", displayName: "Event",
render: function() { render: function() {
let event = this.props.event let event = this.props.event;
let state = "" let state = "";
let reply = "" let reply = "";
let element = "unsupported event: " + event.type let element = "unsupported event: " + event.type;
if (event.local) { if (event.local) {
state = " local" state = " local";
} }
if (event.type == "m.room.message") { if (event.type == "m.room.message") {
let msgtype = event.content.msgtype; let msgtype = event.content.msgtype;
let formattedEvent = parseEvent(event) let formattedEvent = parseEvent(event); /* FIXME: Specify context */
let parsedReply = formattedEvent.parsedReply let parsedReply = formattedEvent.parsedReply;
if (parsedReply.isReply) { if (parsedReply.isReply) {
let repliedEvent = this.props.room.findEventById(parsedReply.to) let repliedEvent = this.props.room.findEventById(parsedReply.to);
let shortText, repliedUser let shortText, repliedUser;
if (repliedEvent == undefined) { if (repliedEvent == undefined) {
shortText = "Can't load this event" shortText = "Can't load this event";
repliedUser = {userId: "NEO_UNKNOWN", displayName: "Unknown User"} repliedUser = {userId: "NEO_UNKNOWN", displayName: "Unknown User"};
// fall back on <mx-reply> content? // fall back on <mx-reply> content?
} else { } else {
repliedUser = this.props.client.getUser(repliedEvent.event.sender) repliedUser = this.props.client.getUser(repliedEvent.event.sender);
shortText = parseEvent(repliedEvent.event) shortText = parseEvent(repliedEvent.event); /* FIXME: Specify context */
if (shortText.html) { if (shortText.html) {
shortText = <span dangerouslySetInnerHTML={{__html: shortText.body}}/> shortText = <span dangerouslySetInnerHTML={{__html: shortText.body}}/>;
} else { } else {
shortText = shortText.body shortText = shortText.body;
} }
} }
reply = ( reply = (
<div className="reply"> <div className="reply">
<User user={repliedUser}/> <User user={repliedUser}/>
{shortText} {shortText}
</div> </div>
) );
} }
element = React.createElement(defaultValue(elements[msgtype], elements["m.text"]), {formattedEvent: formattedEvent, event: event, client: this.props.client}) element = React.createElement(defaultValue(elements[msgtype], elements["m.text"]), {formattedEvent: formattedEvent, event: event, client: this.props.client});
} else if (["m.room.name", "m.room.member", "m.room.avatar"].includes(event.type)) { } else if (["m.room.name", "m.room.member", "m.room.avatar"].includes(event.type)) {
element = React.createElement(stateElement, {event: event}) element = React.createElement(stateElement, {event: event});
} }
return ( return (
<div className={"event" + state} onClick={() => { <div className={"event" + state} onClick={() => {
this.props.onReplyClick(event) this.props.onReplyClick(event);
console.log(event) console.log(event);
}}> }}>
{reply} {reply}
{element} {element}
</div> </div>
) );
} }
}) });
function parseEvent(event, context) { function parseEvent(event, _context) {
// context can be either 'main' or 'reply' // context can be either 'main' or 'reply'
let body = event.content.body let body = event.content.body;
let html = false let html = false;
if (event.content.format == "org.matrix.custom.html") { if (event.content.format == "org.matrix.custom.html") {
body = riot.sanitize(event.content.formatted_body) body = riot.sanitize(event.content.formatted_body);
html = true html = true;
} }
if (body) { if (body) {
body = body.trim() body = body.trim();
} }
let parsedReply = parseReply(event, body) let parsedReply = parseReply(event, body);
if (parsedReply.isReply) { if (parsedReply.isReply) {
// body with fallback stripped // body with fallback stripped
body = parsedReply.body body = parsedReply.body;
} }
return {body: body, parsedReply: parsedReply, html: html} return {body: body, parsedReply: parsedReply, html: html};
} }
function parseReply(event, body) { function parseReply(event, body) {
let replyTo let replyTo;
try { try {
replyTo = event.content['m.relates_to']['m.in_reply_to'].event_id replyTo = event.content['m.relates_to']['m.in_reply_to'].event_id;
if (replyTo) { if (replyTo) {
// strip <mx-reply> from message if it exists // strip <mx-reply> from message if it exists
body = body.replace(mxReplyRegex, "") body = body.replace(mxReplyRegex, "");
} }
} catch(err) { } catch(err) {
// no reply // no reply
return {isReply: false} return {isReply: false};
} }
return {isReply: true, body: body, to: replyTo} return {isReply: true, body: body, to: replyTo};
} }
module.exports = Event module.exports = Event;

@ -1,44 +1,35 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
const Promise = require('bluebird')
const defaultValue = require('default-value')
const mediaLib = require('../../lib/media.js') const mediaLib = require('../../lib/media.js');
const Text = require('./text.js')
let Event = create({ let Event = create({
displayName: "m.image", displayName: "m.image",
getInitialState: function() { getInitialState: function() {
let event = this.props.event let event = this.props.event;
if (event.content.url == undefined) { if (event.content.url == undefined) {
return null return null;
} }
return mediaLib.parseEvent(this.props.client, event, 1000, 1000) return mediaLib.parseEvent(this.props.client, event, 1000, 1000);
}, },
updateSize: function(e) { render: function() {
console.log("image was loaded") let event = this.props.event;
},
if (this.state == null) {
render: function() { return "malformed image event: " + event.content.body;
let event = this.props.event }
if (this.state == null) { return (
return "malformed image event: " + event.content.body <div className="body">
} <a href={this.state.full} target="_blank">
<img src={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}/>
return ( </a>
<div className="body"> </div>
<a href={this.state.full} target="_blank"> );
<img src={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}/> }
</a> });
</div>
)
}
})
module.exports = Event; module.exports = Event;

@ -1,19 +1,18 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
let Event = create({ let Event = create({
displayName: "genericStateEvent", displayName: "genericStateEvent",
render: function() { render: function() {
let event = this.props.event let event = this.props.event;
return ( return (
<div className="body"> <div className="body">
{event.plaintext()} {event.plaintext()}
</div> </div>
) );
} }
}) });
module.exports = Event module.exports = Event;

@ -1,43 +1,39 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
const Promise = require('bluebird')
const riot = require('../../lib/riot-utils.js')
let Event = create({ let Event = create({
displayName: "m.text", displayName: "m.text",
render: function() { render: function() {
let event = this.props.event let event = this.props.event;
let formattedEvent = this.props.formattedEvent let formattedEvent = this.props.formattedEvent;
let eventBody let eventBody;
if (formattedEvent.html) { if (formattedEvent.html) {
eventBody = <div eventBody = <div
className="body" className="body"
dangerouslySetInnerHTML={{__html: formattedEvent.body}} dangerouslySetInnerHTML={{__html: formattedEvent.body}}
/> />;
} else { } else {
eventBody = eventBody =
<div className="body"> <div className="body">
{formattedEvent.body} {formattedEvent.body}
</div> </div>;
} }
let eventClass = "" let eventClass = "";
if (event.local) { if (event.local) {
eventClass += " local" eventClass += " local";
} }
return <div className={eventClass}> return <div className={eventClass}>
{eventBody} {eventBody}
</div> </div>;
} }
}) });
module.exports = Event module.exports = Event;

@ -1,49 +1,27 @@
'use strict' "use strict";
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
const jdenticon = require('jdenticon')
jdenticon.config = { const React = require("react");
lightness: { const create = require("create-react-class");
color: [0.58, 0.66],
grayscale: [0.30, 0.90] const generateJdenticon = require("../../lib/generate-jdenticon");
},
saturation: {
color: 0.66,
grayscale: 0.00
},
backColor: "#00000000"
};
let User = create({ let User = create({
displayName: "user", displayName: "user",
getInitialState: function() { getInitialState: function() {
let icon = jdenticon.toSvg(this.props.user.userId, 200) return {
let match = icon.match(/#([a-f0-9]{6})/g) /* FIXME: Cache this to speed it up */
let color = '#ff0000' color: generateJdenticon(this.props.user.userId).primaryColor()
for(let i=match.length-1; i>= 0; i--) { };
color = match[i] },
let r = color.substr(1, 2)
let g = color.substr(3, 2)
let b = color.substr(5, 2)
if (r != g && g != b) { // not greyscale
break
}
}
return {
color: color
}
},
render: function() { render: function() {
return ( return (
<div className="user" style={{color: this.state.color}}> <div className="user" style={{color: this.state.color}}>
{this.props.user.displayName} {this.props.user.displayName}
</div> </div>
) );
} }
}) });
module.exports = User module.exports = User;

@ -1,40 +1,35 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
const Promise = require('bluebird')
const defaultValue = require('default-value')
const mediaLib = require('../../lib/media.js') const mediaLib = require('../../lib/media.js');
const Text = require('./text.js')
let Event = create({ let Event = create({
displayName: "m.video", displayName: "m.video",
getInitialState: function() { getInitialState: function() {
let event = this.props.event let event = this.props.event;
if (event.content.url == undefined) { if (event.content.url == undefined) {
return null return null;
} }
return mediaLib.parseEvent(this.props.client, event, 1000, 1000) return mediaLib.parseEvent(this.props.client, event, 1000, 1000);
}, },
render: function() { render: function() {
let event = this.props.event let event = this.props.event;
if (this.state == null) { if (this.state == null) {
return "malformed video event: " + event.content.body return "malformed video event: " + event.content.body;
} }
return ( return (
<div className="body"> <div className="body">
<video controls poster={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}> <video controls poster={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}>
<source src={this.state.full}></source> <source src={this.state.full}></source>
</video> </video>
</div> </div>
) );
} }
}) });
module.exports = Event; module.exports = Event;

@ -1,46 +1,44 @@
'use strict' 'use strict';
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
let fileUpload = create({ const Promise = require("bluebird");
displayName: "FileUpload", const React = require('react');
setFileRef: function(e) { const withElement = require("../lib/with-element");
if (e != null) { const fileToDataUrl = require("../lib/file-to-data-url");
e.addEventListener('change', this.startUpload)
this.setState({
fileRef: e
})
}
},
startUpload: function(e) { module.exports = function FileUpload({ addUpload }) {
Array.from(e.target.files).forEach((file) => { function handleChange(event) {
if (file.type.startsWith("image/")) { return Promise.map(Array.from(event.target.files), (file) => {
let reader = new FileReader() if (file.type.startsWith("image/")) {
reader.onloadend = () => { return Promise.try(() => {
let fileObject = { return fileToDataUrl(file);
file: file, }).then((url) => {
preview: reader.result return addUpload({
} file: file,
this.props.addUpload(fileObject) preview: url
} });
reader.readAsDataURL(file) });
} else { } else {
this.props.addUpload({file: file, preview: "/icons/file.svg"}) return addUpload({
} file: file,
}) preview: "/icons/file.svg"
}, });
}
});
}
render: function() { let setFileRef = withElement((element) => {
return ( element.addEventListener("change", handleChange);
<div className="fileUpload">
<input type="file" id="fileUpload" multiple ref={this.setFileRef} />
<label htmlFor="fileUpload"><img src="/icons/file.svg"/></label>
</div>
)
}
})
module.exports = fileUpload return function() {
element.removeEventListener("change", handleChange);
};
});
return (
<div className="fileUpload">
<input type="file" id="fileUpload" multiple ref={setFileRef} />
<label htmlFor="fileUpload"><img src="/icons/file.svg"/></label>
</div>
);
};

@ -1,64 +1,62 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class') const debounce = require('debounce');
const Promise = require('bluebird')
const debounce = require('debounce')
let FilterList = create({ let FilterList = create({
displayName: "FilterList", displayName: "FilterList",
getInitialState: function() { getInitialState: function() {
return { return {
selection: "room0", selection: "room0",
filter: "" filter: ""
} };
}, },
select: function(id) { select: function(id) {
this.setState({selection: id, filter: ""}) this.setState({selection: id, filter: ""});
this.state.inputRef.value = "" this.state.inputRef.value = "";
this.props.callback(id) this.props.callback(id);
}, },
inputRef: function(ref) { inputRef: function(ref) {
if (ref == null) { if (ref == null) {
return return;
} }
this.setState({ this.setState({
inputRef: ref inputRef: ref
}) });
ref.addEventListener("keyup", debounce(this.input, 20)) ref.addEventListener("keyup", debounce(this.input, 20));
}, },
input: function(e) { input: function(e) {
this.setState({ this.setState({
filter: e.target.value.toUpperCase() filter: e.target.value.toUpperCase()
}) });
}, },
render: function() { render: function() {
let keys = Object.keys(this.props.items) let keys = Object.keys(this.props.items);
let items = keys.map((itemKey, id) => { let items = keys.map((itemKey) => {
let item = this.props.items[itemKey] let item = this.props.items[itemKey];
let props = { let props = {
selected: this.state.selection == itemKey, selected: this.state.selection == itemKey,
filter: this.state.filter, filter: this.state.filter,
content: item, content: item,
key: itemKey, key: itemKey,
listId: itemKey, listId: itemKey,
select: this.select, select: this.select,
properties: this.props.properties properties: this.props.properties
} };
return React.createElement(this.props.element, props) return React.createElement(this.props.element, props);
}) });
return <> return <>
<input className="filter" ref={this.inputRef} placeholder="Search"/> <input className="filter" ref={this.inputRef} placeholder="Search"/>
<div className="list"> <div className="list">
{items} {items}
</div> </div>
</> </>;
} }
}) });
module.exports = FilterList module.exports = FilterList;

@ -1,19 +1,17 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
const Promise = require('bluebird')
let info = create({ let info = create({
displayName: "Info", displayName: "Info",
render: function() { render: function() {
let title = "" let title = "";
if (this.props.room != undefined) { if (this.props.room != undefined) {
title = this.props.room.name title = this.props.room.name;
} }
return <div className="info">{title}</div> return <div className="info">{title}</div>;
} }
}) });
module.exports = info module.exports = info;

@ -1,277 +1,281 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class') const Promise = require('bluebird');
const Promise = require('bluebird') const colorConvert = require('color-convert');
const colorConvert = require('color-convert') const sanitize = require('sanitize-html');
const sanitize = require('sanitize-html')
const riot = require('../lib/riot-utils.js') const riot = require('../lib/riot-utils.js');
const FileUpload = require('./fileUpload.js') const FileUpload = require('./fileUpload.js');
let input = create({ let input = create({
displayName: "Input", displayName: "Input",
getInitialState: function() { getInitialState: function() {
return { return {
uploads: [] uploads: []
} };
}, },
setRef: function(ref) { setRef: function(ref) {
if (ref !=null) { if (ref !=null) {
ref.addEventListener("keydown", (e) => { ref.addEventListener("keydown", (e) => {
// only send on plain 'enter' // only send on plain 'enter'
if (e.key == "Enter") { if (e.key == "Enter") {
if (!e.shiftKey && !e.altKey && !e.ctrlKey) { if (!e.shiftKey && !e.altKey && !e.ctrlKey) {
this.send(e) this.send(e);
return return;
} }
} }
}) });
ref.addEventListener('change', this.resize_textarea) ref.addEventListener('change', this.resize_textarea);
ref.addEventListener('cut', this.resize_textarea_delayed) ref.addEventListener('cut', this.resize_textarea_delayed);
ref.addEventListener('paste', this.resize_textarea_delayed) ref.addEventListener('paste', this.resize_textarea_delayed);
ref.addEventListener('drop', this.resize_textarea_delayed) ref.addEventListener('drop', this.resize_textarea_delayed);
ref.addEventListener('keydown', this.resize_textarea_delayed) ref.addEventListener('keydown', this.resize_textarea_delayed);
this.setState({ref: ref}) this.setState({ref: ref});
} }
}, },
addUpload: function(upload) { addUpload: function(upload) {
let uploads = this.state.uploads let uploads = this.state.uploads;
uploads.push(upload) uploads.push(upload);
this.setState({uploads: uploads}) this.setState({uploads: uploads});
}, },
removeUpload: function(index) { removeUpload: function(index) {
let uploads = this.state.uploads let uploads = this.state.uploads;
uploads.splice(index, 1) uploads.splice(index, 1);
this.setState({uploads: uploads}) this.setState({uploads: uploads});
}, },
resize_textarea: function(element) { resize_textarea: function(element) {
if (element == undefined) { if (element == undefined) {
return; return;
} }
let ref = element.target; let ref = element.target;
if (ref != undefined) { if (ref != undefined) {
ref.style.height = 'auto'; ref.style.height = 'auto';
ref.style.height = ref.scrollHeight+'px'; ref.style.height = ref.scrollHeight+'px';
} }
}, },
resize_textarea_delayed: function(e) { resize_textarea_delayed: function(e) {
setTimeout(() => this.resize_textarea(e), 5); setTimeout(() => this.resize_textarea(e), 5);
}, },
send: function(e) { send: function(e) {
let msg = e.target.value let msg = e.target.value;
if (msg.trim().length != 0) { if (msg.trim().length != 0) {
//TODO: parse markdown (commonmark?) //TODO: parse markdown (commonmark?)
if (msg.startsWith('/')) { if (msg.startsWith('/')) {
// Handle other commands // Handle other commands
let parts = msg.split(' ') let parts = msg.split(' ');
let command = parts[0] let command = parts[0];
let result = handleCommands(command, parts) let result = handleCommands(command, parts);
if (result != null) { if (result != null) {
if (result.type == "html") { if (result.type == "html") {
this.sendHTML(result.content) this.sendHTML(result.content);
} else { } else {
this.sendPlain(result.content) this.sendPlain(result.content);
} }
} }
} else { } else {
this.sendPlain(msg) this.sendPlain(msg);
} }
} }
if (this.state.uploads.length > 0) { if (this.state.uploads.length > 0) {
this.uploadFiles(this.state.uploads) this.uploadFiles(this.state.uploads);
this.setState({uploads: []}) this.setState({uploads: []});
} }
e.target.value = "" e.target.value = "";
e.preventDefault() e.preventDefault();
this.resize_textarea_delayed(e) this.resize_textarea_delayed(e);
}, },
uploadFiles: function(uploads) { uploadFiles: function(uploads) {
let client = this.props.client let client = this.props.client;
Promise.map(uploads, (upload) => {
let fileUploadPromise = client.uploadContent(upload.file,
{onlyContentUri: false}).then((response) => {
return response.content_uri
})
let mimeType = upload.file.type return Promise.map(uploads, (upload) => {
let eventType = "m.file" let fileUploadPromise = client.uploadContent(upload.file,
let additionalPromise {onlyContentUri: false}).then((response) => {
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { return response.content_uri;
function elementToThumbnail(element) { });
return new Promise((resolve, reject) => {
riot.createThumbnail(element,
element.width,
element.height,
thumbnailType
)
.catch((error) => {
console.error("neo: error getting thumbnail", error)
reject()
})
.then((thumbResult) => {
return client.uploadContent(thumbResult.thumbnail, {onlyContentUri: false})
}).then((response) => {
return resolve({
thumbnail_url: response.content_uri,
thumbnail_info: {
mimetype: thumbnailType
}
})
})
}) let mimeType = upload.file.type;
} let eventType = "m.file";
if (mimeType.startsWith("image/")) { let additionalPromise;
eventType = "m.image" if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) {
additionalPromise = riot.loadImageElement(upload.file) function elementToThumbnail(element) {
.then((element) => {return elementToThumbnail(element)}) return Promise.try(() => {
} else if (mimeType.startsWith("video/")) { return riot.createThumbnail(element,
eventType = "m.video" element.width,
additionalPromise = riot.loadVideoElement(upload.file) element.height,
.then((element) => {return elementToThumbnail(element)}) thumbnailType
} );
// create and upload thumbnail }).catch((error) => {
let thumbnailType = "image/png" console.error("neo: error getting thumbnail", error);
if (mimeType == "image/jpeg") {
thumbnailType = mimeType
}
} else if (mimeType.startsWith("audio/")) {
eventType = "m.audio"
} else {
// m.file
}
Promise.all([fileUploadPromise, additionalPromise]).then((result) => {
console.log(result)
let info = {
mimetype: mimeType
}
if (result[1] != undefined) {
info = Object.assign(info, result[1])
}
client.sendEvent(this.props.roomId, "m.room.message", {
body: upload.file.name,
msgtype: eventType,
info: info,
url: result[0]
})
})
})
},
sendPlain: function(string) { throw error;
let content = { }).then((thumbResult) => {
body: string, return client.uploadContent(thumbResult.thumbnail, {onlyContentUri: false});
msgtype: "m.text" }).then((response) => {
} return {
content = this.sendReply(content) thumbnail_url: response.content_uri,
this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, res) => { thumbnail_info: {
if (err != null) { mimetype: thumbnailType
console.log(err) }
} };
}) });
}, }
if (mimeType.startsWith("image/")) {
eventType = "m.image";
additionalPromise = riot.loadImageElement(upload.file)
.then((element) => {return elementToThumbnail(element);});
} else if (mimeType.startsWith("video/")) {
eventType = "m.video";
additionalPromise = riot.loadVideoElement(upload.file)
.then((element) => {return elementToThumbnail(element);});
}
// create and upload thumbnail
let thumbnailType = "image/png";
sendHTML: function(html) { if (mimeType == "image/jpeg") {
let content = { thumbnailType = mimeType;
body: sanitize(html, {allowedTags: []}), }
formatted_body: html, } else if (mimeType.startsWith("audio/")) {
format: "org.matrix.custom.html", eventType = "m.audio";
msgtype: "m.text" } else {
} // m.file
}
content = this.sendReply(content) return Promise.all([fileUploadPromise, additionalPromise]).then((result) => {
console.log(result);
let info = {
mimetype: mimeType
};
this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, res) => { if (result[1] != undefined) {
console.log(err) info = Object.assign(info, result[1]);
}) }
},
sendReply: function(content) { return client.sendEvent(this.props.roomId, "m.room.message", {
if (this.props.replyEvent != undefined) { body: upload.file.name,
content['m.relates_to'] = { msgtype: eventType,
'm.in_reply_to': { info: info,
event_id: this.props.replyEvent.event_id url: result[0]
} });
} });
this.props.onReplyClick() });
} },
return content
},
render: function() { sendPlain: function(string) {
return <div className="input"> let content = {
{this.props.replyEvent && body: string,
<div className="replyEvent" onClick={() => this.props.onReplyClick()}> msgtype: "m.text"
{this.props.replyEvent.plaintext()} };
</div> content = this.sendReply(content);
} /* FIXME: Promisify */
{this.state.uploads.length > 0 && this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, _res) => {
<div className="imgPreview"> if (err != null) {
{this.state.uploads.map((upload, key) => { console.log(err);
return ( }
<div key={key}> });
<img src={upload.preview}/> },
<span onClick={() => this.removeUpload(key)}>X</span>
</div> sendHTML: function(html) {
) let content = {
})} body: sanitize(html, {allowedTags: []}),
</div> formatted_body: html,
} format: "org.matrix.custom.html",
<div className="content"> msgtype: "m.text"
<textarea ref={this.setRef} rows="1" spellCheck="false" placeholder="unencrypted message"></textarea> };
<FileUpload addUpload={this.addUpload}/>
</div> content = this.sendReply(content);
</div>
} /* FIXME: Promisify */
}) this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, _res) => {
console.log(err);
});
},
sendReply: function(content) {
if (this.props.replyEvent != undefined) {
content['m.relates_to'] = {
'm.in_reply_to': {
event_id: this.props.replyEvent.event_id
}
};
this.props.onReplyClick();
}
return content;
},
render: function() {
return <div className="input">
{this.props.replyEvent &&
<div className="replyEvent" onClick={() => this.props.onReplyClick()}>
{this.props.replyEvent.plaintext()}
</div>
}
{this.state.uploads.length > 0 &&
<div className="imgPreview">
{this.state.uploads.map((upload, key) => {
return (
<div key={key}>
<img src={upload.preview}/>
<span onClick={() => this.removeUpload(key)}>X</span>
</div>
);
})}
</div>
}
<div className="content">
<textarea ref={this.setRef} rows="1" spellCheck="false" placeholder="unencrypted message"></textarea>
<FileUpload addUpload={this.addUpload}/>
</div>
</div>;
}
});
function handleCommands(command, parts) { function handleCommands(command, parts) {
if (command == "/rainbow") { if (command == "/rainbow") {
if (parts.length < 2) { if (parts.length < 2) {
return return;
} }
let string = parts[1] let string = parts[1];
for(let i=2; i < parts.length; i++) { for(let i=2; i < parts.length; i++) {
string += " " + parts[i] string += " " + parts[i];
} }
let html = rainbowTransform(string) let html = rainbowTransform(string);
return { return {
type: 'html', type: 'html',
content: html content: html
} };
} }
return null return null;
} }
function rainbowTransform(text) { function rainbowTransform(text) {
let array = text.split(""); let array = text.split("");
let delta = 360/text.length; let delta = 360/text.length;
if (delta < 10) { if (delta < 10) {
delta = 10; delta = 10;
} else if (delta > 20) { } else if (delta > 20) {
delta = 20; delta = 20;
} }
let h = -1 * delta; // start at beginning let h = -1 * delta; // start at beginning
let rainbowArray = array.map((char) => { let rainbowArray = array.map((char) => {
h = h + delta; h = h + delta;
if (h > 360) { if (h > 360) {
h = 0; h = 0;
} }
return `<font color="${colorConvert.hsl.hex(h, 100, 50)}">${char}</font>`; return `<font color="${colorConvert.hsl.hex(h, 100, 50)}">${char}</font>`;
}); });
let rainbow = rainbowArray.join(""); let rainbow = rainbowArray.join("");
return rainbow; return rainbow;
} }
module.exports = input module.exports = input;

@ -1,20 +1,19 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class')
let Loading = create({ let Loading = create({
displayName: "Loading", displayName: "Loading",
render: function() { render: function() {
return ( return (
<div className="spinner"> <div className="spinner">
<div className="bounce1"/> <div className="bounce1"/>
<div className="bounce2"/> <div className="bounce2"/>
<div className="bounce3"/> <div className="bounce3"/>
</div> </div>
) );
} }
}) });
module.exports = Loading module.exports = Loading;

@ -1,117 +1,114 @@
'use strict' 'use strict';
const React = require('react') const React = require('react');
const ReactDOM = require('react-dom') const create = require('create-react-class');
const create = require('create-react-class') const jdenticon = require('jdenticon');
const Promise = require('bluebird')
const debounce = require('debounce')
const jdenticon = require('jdenticon')
const FilterList = require('./filterList.js') const FilterList = require('./filterList.js');
let RoomListItem = create({ let RoomListItem = create({
displayName: "RoomListItem", displayName: "RoomListItem",
getInitialState: function() { getInitialState: function() {
let room = this.props.content let room = this.props.content;
let client = this.props.properties.client let client = this.props.properties.client;
let jdenticon = <svg id="avatar" ref={this.jdenticonRef}/> let jdenticon = <svg id="avatar" ref={this.jdenticonRef}/>;
let avatarUrl let avatarUrl;
let roomState = room.getLiveTimeline().getState('f') let roomState = room.getLiveTimeline().getState('f');
let avatarState = roomState.getStateEvents('m.room.avatar') let avatarState = roomState.getStateEvents('m.room.avatar');
if (avatarState.length > 0) { if (avatarState.length > 0) {
let event = avatarState[avatarState.length-1].event let event = avatarState[avatarState.length-1].event;
let hs = client.baseUrl let hs = client.baseUrl;
let media_mxc = event.content.url.slice(6) let media_mxc = event.content.url.slice(6);
let path = `/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale` let path = `/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale`;
avatarUrl = { avatarUrl = {
hs: hs, hs: hs,
path: path path: path
} };
} }
return { return {
filterName: room.name.toUpperCase(), filterName: room.name.toUpperCase(),
unread: Math.random() > 0.7, unread: Math.random() > 0.7,
avatarUrl: avatarUrl, avatarUrl: avatarUrl,
jdenticon: jdenticon, jdenticon: jdenticon,
tries: 0 tries: 0
} };
}, },
jdenticonRef: function(ref) { jdenticonRef: function(ref) {
jdenticon.update(ref, this.props.content.roomId) jdenticon.update(ref, this.props.content.roomId);
}, },
avatarFallback: function() { avatarFallback: function() {
// instead of falling back on jdenticon immediately, we can try // instead of falling back on jdenticon immediately, we can try
// a third-party homeserver's media repo // a third-party homeserver's media repo
// this does come with trust issues, and is opt-in in settings // this does come with trust issues, and is opt-in in settings
let fallbackMediaRepos = this.props.properties.options.fallbackMediaRepos let fallbackMediaRepos = this.props.properties.options.fallbackMediaRepos;
if (this.state.tries < fallbackMediaRepos.length) { if (this.state.tries < fallbackMediaRepos.length) {
let avatarUrl = this.state.avatarUrl let avatarUrl = this.state.avatarUrl;
avatarUrl.hs = fallbackMediaRepos[this.state.tries] avatarUrl.hs = fallbackMediaRepos[this.state.tries];
this.setState({ this.setState({
avatarUrl: avatarUrl, avatarUrl: avatarUrl,
tries: this.state.tries + 1 tries: this.state.tries + 1
}) });
} else { } else {
this.setState({avatarUrl: null, avatar: jdenticon}) this.setState({avatarUrl: null, avatar: jdenticon});
} }
}, },
setRef: function(ref) { setRef: function(ref) {
if (ref == null) { if (ref == null) {
return return;
} }
this.setState({ref: ref}) this.setState({ref: ref});
ref.addEventListener("click", () => {this.props.select(this.props.listId)}) ref.addEventListener("click", () => {this.props.select(this.props.listId);});
}, },
render: function() { render: function() {
if (this.state.filterName.indexOf(this.props.filter) == -1) { if (this.state.filterName.indexOf(this.props.filter) == -1) {
return null return null;
} }
let className = "roomListItem" let className = "roomListItem";
if (this.props.selected) { if (this.props.selected) {
className += " active" className += " active";
} }
if (this.state.unread) { if (this.state.unread) {
className += " unread" className += " unread";
} }
return <div className={className} ref={this.setRef}> return <div className={className} ref={this.setRef}>
{this.state.avatarUrl ? {this.state.avatarUrl ?
<img id="avatar" src={`${this.state.avatarUrl.hs}${this.state.avatarUrl.path}`} onError={this.avatarFallback}></img> <img id="avatar" src={`${this.state.avatarUrl.hs}${this.state.avatarUrl.path}`} onError={this.avatarFallback}></img>
: :
this.state.jdenticon this.state.jdenticon
} }
<span id="name">{this.props.content.name}</span> <span id="name">{this.props.content.name}</span>
</div> </div>;
} }
}) });
let Sidebar = create({ let Sidebar = create({
displayName: "Sidebar", displayName: "Sidebar",
getInitialState: function() { getInitialState: function() {
return { return {
filter: "" filter: ""
} };
}, },
setFilter: function(filter) { setFilter: function(filter) {
this.setState({ this.setState({
filter: filter.toUpperCase() filter: filter.toUpperCase()
}) });
}, },
render: function() { render: function() {
return <div className="sidebar"> return <div className="sidebar">
<FilterList items={this.props.rooms} properties={{client: this.props.client, options: this.props.options}} element={RoomListItem} callback={(roomId) => {this.props.selectRoom(roomId)}}/> <FilterList items={this.props.rooms} properties={{client: this.props.client, options: this.props.options}} element={RoomListItem} callback={(roomId) => {this.props.selectRoom(roomId);}}/>
</div> </div>;
} }
}) });
module.exports = Sidebar module.exports = Sidebar;

@ -0,0 +1,27 @@
"use strict";
const Promise = require("bluebird");
module.exports = function createApiRequester(apiUrl) {
return function apiRequest(path, body) {
return Promise.try(() => {
let targetUrl = apiUrl + path;
return Promise.try(() => {
return fetch(targetUrl, {
body: JSON.stringify(body),
headers: {
'content-type': 'application/json'
},
method: 'POST',
});
}).then((response) => {
if (response.status >= 200 && response.status < 400) {
return response.json();
} else {
throw new Error(`Non-200 response code for ${targetUrl}: ${response.status}`);
}
});
});
};
};

@ -0,0 +1,19 @@
"use strict";
const Promise = require("bluebird");
module.exports = function readFileAsDataUrl(dataUrl) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(event) {
resolve(event.target.result);
};
reader.onerror = function(error) {
reject(error);
};
reader.readAsDataURL(dataUrl);
});
};

@ -0,0 +1,49 @@
"use strict";
const jdenticon = require("jdenticon");
const defaultValue = require("default-value");
module.exports = function generateJdenticon(name) {
function setConfig() {
/* NOTE: This is a hack to ensure that any other code using the `jdenticon` library can't mess with our config, since it's set globally. This function gets called prior to *every* jdenticon-generating operation. */
jdenticon.config = {
lightness: {
color: [0.58, 0.66],
grayscale: [0.30, 0.90]
},
saturation: {
color: 0.66,
grayscale: 0.00
},
backColor: "#00000000"
};
}
return {
toSvg: function (size) {
setConfig();
return jdenticon.toSvg(name, size);
},
primaryColor: function () {
let svg = this.toSvg();
let color = svg.match(/#([a-f0-9]{6})/g).find((candidate) => {
let r = candidate.substr(1, 2);
let g = candidate.substr(3, 2);
let b = candidate.substr(5, 2);
let isGrayScale = (r === g && g === b);
return !isGrayScale;
});
return defaultValue(color, "#ff0000");
},
update: function (element) {
setConfig();
jdenticon.update(element, name);
}
}
};

@ -0,0 +1,15 @@
"use strict";
const { validateOptions, required, isNumber, isString } = require("validatem");
const isMXC = require("./validate/is-mxc");
module.exports = function generateThumbnailUrl({homeserver, mxc, width, height }) {
validateOptions(arguments, {
homeserver: [ required, isString ],
mxc: [ required, isMXC ],
width: [ required, isNumber ],
height: [ required, isNumber ]
});
return `${homeserver}/_matrix/media/v1/thumbnail/${mxc.homeserver}/${mxc.id}?width=${width}&height=${height}&method=scale`;
};

@ -0,0 +1,34 @@
"use strict";
module.exports = function groupEvents(events) {
let currentSender = "";
let currentType = "";
let currentEvents = [];
let eventGroups = [];
function finalizeGroup() {
if (currentEvents.length > 0) {
eventGroups.push({
sender: currentSender,
events: currentEvents
});
currentEvents = [];
}
}
events.forEach((event) => {
/* TODO: Eventually group multiple non-message events from a single user into a single event item as well, even when they are of different types */
if (event.sender !== currentSender || event.type !== currentType) {
finalizeGroup();
currentSender = event.sender;
currentType = event.type;
}
currentEvents.push(event);
});
finalizeGroup();
return eventGroups;
};

@ -1,56 +1,58 @@
"use strict";
// should be able to handle images, stickers, and video // should be able to handle images, stickers, and video
module.exports = { module.exports = {
parseEvent: function(client, event, maxHeight, maxWidth) { parseEvent: function(client, event, maxHeight, maxWidth) {
if (event.content.msgtype == "m.image") { if (event.content.msgtype == "m.image") {
let h = maxHeight let h = maxHeight;
let w = maxWidth let w = maxWidth;
let media_url = client.mxcUrlToHttp(event.content.url) let media_url = client.mxcUrlToHttp(event.content.url);
let thumb_url = event.content.url let thumb_url = event.content.url;
if (event.content.info != null) { if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) { if (event.content.info.thumbnail_url != null) {
thumb_url = event.content.info.thumbnail_url thumb_url = event.content.info.thumbnail_url;
} }
if (event.content.info.thumbnail_info != null) { if (event.content.info.thumbnail_info != null) {
h = (event.content.info.thumbnail_info.h < maxHeight) ? event.content.info.thumbnail_info.h : h h = (event.content.info.thumbnail_info.h < maxHeight) ? event.content.info.thumbnail_info.h : h;
w = (event.content.info.thumbnail_info.w < maxWidth) ? event.content.info.thumbnail_info.w : w w = (event.content.info.thumbnail_info.w < maxWidth) ? event.content.info.thumbnail_info.w : w;
} else { } else {
h = (event.content.info.h < maxHeight) ? event.content.info.h : h h = (event.content.info.h < maxHeight) ? event.content.info.h : h;
w = (event.content.info.w < maxWidth) ? event.content.info.w : w w = (event.content.info.w < maxWidth) ? event.content.info.w : w;
} }
} }
thumb_url = client.mxcUrlToHttp(thumb_url, w, h, 'scale', false) thumb_url = client.mxcUrlToHttp(thumb_url, w, h, 'scale', false);
return { return {
full: media_url, full: media_url,
thumb: thumb_url, thumb: thumb_url,
size: {h, w} size: {h, w}
} };
} }
if (event.content.msgtype == "m.video") { if (event.content.msgtype == "m.video") {
let thumb = null let thumb = null;
let h = maxHeight let h = maxHeight;
let w = maxWidth let w = maxWidth;
if (event.content.info != null) { if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) { if (event.content.info.thumbnail_url != null) {
if (event.content.info.thumbnail_info != null) { if (event.content.info.thumbnail_info != null) {
h = (event.content.info.thumbnail_info.h < maxHeight) ? event.content.info.thumbnail_info.h : h h = (event.content.info.thumbnail_info.h < maxHeight) ? event.content.info.thumbnail_info.h : h;
w = (event.content.info.thumbnail_info.w < maxWidth) ? event.content.info.thumbnail_info.w : w w = (event.content.info.thumbnail_info.w < maxWidth) ? event.content.info.thumbnail_info.w : w;
} }
thumb = client.mxcUrlToHttp(event.content.thumbnail, w, h, 'scale', false) thumb = client.mxcUrlToHttp(event.content.thumbnail, w, h, 'scale', false);
} }
} }
return { return {
full: client.mxcUrlToHttp(event.content.url), full: client.mxcUrlToHttp(event.content.url),
thumb: thumb, thumb: thumb,
size: {h, w} size: {h, w}
} };
} }
} }
} };

@ -0,0 +1,16 @@
"use strict";
const urlLib = require("url");
module.exports = function parseMXC(uri) {
let parsed = urlLib.parse(uri);
if (parsed.protocol === "mxc:" && parsed.slashes === true) {
return {
homeserver: parsed.host,
id: parsed.pathname.replace(/^\/+/, "")
};
} else {
throw new Error("Specified URI is not an MXC URI");
}
};

@ -1,6 +1,9 @@
'use strict'; 'use strict';
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
With modifications, by f0x
With modifications, by Sven Slootweg <admin@cryto.net>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
@ -14,7 +17,9 @@ limitations under the License.
const Promise = require('bluebird'); const Promise = require('bluebird');
const sanitize = require('sanitize-html'); const sanitize = require('sanitize-html');
//require("blueimp-canvas-to-blob");
const fileToDataUrl = require("./file-to-data-url");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
/** /**
@ -36,194 +41,220 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
* and a thumbnail key. * and a thumbnail key.
*/ */
function canvasToBlob(canvas, mimeType) {
return new Promise((resolve, _reject) => {
canvas.toBlob(function(blob) {
resolve(blob);
}, mimeType);
});
}
function awaitImageLoad(image) {
return new Promise((resolve, reject) => {
image.onload = function() {
resolve();
};
image.onerror = function(e) {
reject(e);
};
});
}
function awaitVideoLoad(video) {
return new Promise((resolve, reject) => {
video.onloadeddata = function() {
resolve({
width: video.videoWidth,
height: video.videoHeight
});
};
video.onerror = function(error) {
reject(error);
};
});
}
module.exports = { module.exports = {
createThumbnail: function(element, inputWidth, inputHeight, mimeType) { createThumbnail: function(element, inputWidth, inputHeight, mimeType) {
return new Promise(function(resolve, reject) { return Promise.try(() => {
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
let targetWidth = inputWidth; let targetWidth = inputWidth;
let targetHeight = inputHeight; let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); if (targetHeight > MAX_HEIGHT) {
targetHeight = MAX_HEIGHT; targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
} targetHeight = MAX_HEIGHT;
if (targetWidth > MAX_WIDTH) { }
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH; if (targetWidth > MAX_WIDTH) {
} targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
const canvas = document.createElement("canvas"); }
canvas.width = targetWidth;
canvas.height = targetHeight; const canvas = Object.assign(document.createElement("canvas"), {
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); width: targetWidth,
canvas.toBlob(function(thumbnail) { height: targetHeight
resolve({ });
info: {
thumbnail_info: { canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
w: targetWidth,
h: targetHeight, return Promise.try(() => {
mimetype: thumbnail.type, return canvasToBlob(canvas, mimeType);
size: thumbnail.size, }).then((thumbnail) => {
}, return {
w: inputWidth, info: {
h: inputHeight, thumbnail_info: {
}, w: targetWidth,
thumbnail: thumbnail, h: targetHeight,
}); mimetype: thumbnail.type,
}, mimeType); size: thumbnail.size,
}); },
}, w: inputWidth,
h: inputHeight,
/** },
thumbnail: thumbnail,
};
});
});
},
/**
* Load a file into a newly created image element. * Load a file into a newly created image element.
* *
* @param {File} file The file to load in an image element. * @param {File} file The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element. * @return {Promise} A promise that resolves with the html image element.
*/ */
loadImageElement: function(imageFile) { loadImageElement: function(imageFile) {
return new Promise(function(resolve, reject) { return Promise.try(() => {
// Load the file into an html element const img = document.createElement("img");
const img = document.createElement("img"); const objectUrl = URL.createObjectURL(imageFile);
const objectUrl = URL.createObjectURL(imageFile); img.src = objectUrl;
img.src = objectUrl;
return Promise.try(() => {
// Once ready, create a thumbnail return awaitImageLoad(img);
img.onload = function() { }).then(() => {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
resolve(img);
}; return img;
img.onerror = function(e) { });
reject(e); });
}; },
});
}, /**
/**
* Load a file into a newly created video element. * Load a file into a newly created video element.
* *
* @param {File} file The file to load in an video element. * @param {File} file The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element. * @return {Promise} A promise that resolves with the video image element.
*/ */
loadVideoElement: function(videoFile) { loadVideoElement: function(videoFile) {
return new Promise(function(resolve, reject) { return Promise.try(() => {
// Load the file into an html element return fileToDataUrl(videoFile);
const video = document.createElement("video"); }).then((url) => {
const video = Object.assign(document.createElement("video"), {
const reader = new FileReader(); src: url
reader.onload = function(e) { });
video.src = e.target.result;
return Promise.try(() => {
// Once ready, returns its size return awaitVideoLoad(video);
// Wait until we have enough data to thumbnail the first frame. }).then((dimensions) => {
video.onloadeddata = function() { /* FIXME: Check whether this can be improved, it's a bit dirty to shoehorn the dimensions onto the video object like this */
video.width = video.videoWidth return Object.assign(video, dimensions);
video.height = video.videoHeight });
resolve(video); });
}; },
video.onerror = function(e) {
reject(e); sanitize: function(html) {
}; return sanitize(html, this.sanitizeHtmlParams);
}; },
reader.onerror = function(e) {
reject(e); sanitizeHtmlParams: {
}; allowedTags: [
reader.readAsDataURL(videoFile); 'font', // custom to matrix for IRC-style font coloring
}); 'del', // for markdown
}, 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
sanitize: function(html) { 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
return sanitize(html, this.sanitizeHtmlParams); 'mx-reply', 'mx-rainbow'
}, ],
allowedAttributes: {
sanitizeHtmlParams: { // custom ones first:
allowedTags: [ font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
'font', // custom to matrix for IRC-style font coloring span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
'del', // for markdown a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a', 'ul', 'ol', 'sup', 'sub', img: ['src', 'width', 'height', 'alt', 'title'],
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', ol: ['start'],
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', code: ['class'], // We don't actually allow all classes, we filter them in transformTags
'mx-reply', 'mx-rainbow' },
], // Lots of these won't come up by default because we don't allow them
allowedAttributes: { selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// custom ones first: // URL schemes we permit
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix allowProtocolRelative: false,
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'], transformTags: { // custom to matrix
code: ['class'], // We don't actually allow all classes, we filter them in transformTags // add blank targets to all hyperlinks except vector URLs
}, 'img': function(tagName, _attribs) {
// Lots of these won't come up by default because we don't allow them // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // because transformTags is used _before_ we filter by allowedSchemesByTag and
// URL schemes we permit // we don't want to allow images with `https?` `src`s.
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], //if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
allowProtocolRelative: false, //}
//attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
transformTags: { // custom to matrix // attribs.src,
// add blank targets to all hyperlinks except vector URLs // attribs.width || 800,
'img': function(tagName, attribs) { // attribs.height || 600
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag //);
// because transformTags is used _before_ we filter by allowedSchemesByTag and //return { tagName: tagName, attribs: attribs };
// we don't want to allow images with `https?` `src`s. },
//if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}}; 'code': function(tagName, attribs) {
//} if (typeof attribs.class !== 'undefined') {
//attribs.src = MatrixClientPeg.get().mxcUrlToHttp( // Filter out all classes other than ones starting with language- for syntax highlighting.
// attribs.src, const classes = attribs.class.split(/\s+/).filter(function(cl) {
// attribs.width || 800, return cl.startsWith('language-');
// attribs.height || 600 });
//); attribs.class = classes.join(' ');
//return { tagName: tagName, attribs: attribs }; }
}, return {
tagName: tagName,
'code': function(tagName, attribs) { attribs: attribs,
if (typeof attribs.class !== 'undefined') { };
// Filter out all classes other than ones starting with language- for syntax highlighting. },
const classes = attribs.class.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-'); '*': function(tagName, attribs) {
}); // Delete any style previously assigned, style is an allowedTag for font and span
attribs.class = classes.join(' '); // because attributes are stripped after transforming
} delete attribs.style;
return {
tagName: tagName, // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
attribs: attribs, // equivalents
}; const customCSSMapper = {
}, 'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
'*': function(tagName, attribs) { // $customAttributeKey: $cssAttributeKey
// Delete any style previously assigned, style is an allowedTag for font and span };
// because attributes are stripped after transforming
delete attribs.style; let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS const cssAttributeKey = customCSSMapper[customAttributeKey];
// equivalents const customAttributeValue = attribs[customAttributeKey];
const customCSSMapper = { if (customAttributeValue && typeof customAttributeValue === 'string' && COLOR_REGEX.test(customAttributeValue)) {
'data-mx-color': 'color', style += cssAttributeKey + ":" + customAttributeValue + ";";
'data-mx-bg-color': 'background-color', delete attribs[customAttributeKey];
// $customAttributeKey: $cssAttributeKey }
}; });
let style = ""; if (style) {
Object.keys(customCSSMapper).forEach((customAttributeKey) => { attribs.style = style;
const cssAttributeKey = customCSSMapper[customAttributeKey]; }
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue && return { tagName: tagName, attribs: attribs };
typeof customAttributeValue === 'string' && },
COLOR_REGEX.test(customAttributeValue) },
) { }
style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey];
}
});
if (style) {
attribs.style = style;
}
return { tagName: tagName, attribs: attribs };
},
},
}
}; };

@ -0,0 +1,7 @@
"use strict";
module.exports = function isMXC(value) {
if (value.homeserver == null || value.id == null) {
throw new Error("Must be an MXC object");
}
};

@ -0,0 +1,15 @@
"use strict";
const React = require("react");
module.exports = function withElement(callback) {
let [ elementRef, setElementRef ] = React.useState();
React.useEffect(() => {
if (elementRef != null) {
callback(elementRef);
}
}, [ elementRef ]);
return setElementRef;
};

@ -1,89 +1,94 @@
let assert = require('assert') "use strict";
let urllib = require('url')
let querystring = require('querystring')
let mediaLib = require('../../lib/media.js') /* global describe, it */
let assert = require('assert');
let urllib = require('url');
let querystring = require('querystring');
let mediaLib = require('../../lib/media.js');
let client = { let client = {
mxcUrlToHttp: function(url, w, h, method, allowDirectLinks) { /* FIXME: Verify whether allowDirectLinks is used / expected to do anything by other code */
let hs = "https://chat.privacytools.io" mxcUrlToHttp: function(url, w, h, method, _allowDirectLinks) {
let mxc = url.slice(6) let hs = "https://chat.privacytools.io";
if (w) { let mxc = url.slice(6);
return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}` if (w) {
} else { return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}`;
return `${hs}/_matrix/media/v1/download/${mxc}` } else {
} return `${hs}/_matrix/media/v1/download/${mxc}`;
} }
} }
};
let mockEventTemplate = { let mockEventTemplate = {
type: "m.room.message", type: "m.room.message",
sender: "@f0x:privacytools.io", sender: "@f0x:privacytools.io",
content: { content: {
body: "image.png", body: "image.png",
info: { info: {
size: 16692, size: 16692,
mimetype: "image/png", mimetype: "image/png",
thumbnail_info: { thumbnail_info: {
w: 268, w: 268,
h: 141, h: 141,
mimetype: "image/png", mimetype: "image/png",
size: 16896 size: 16896
}, },
w: 268, w: 268,
h: 141, h: 141,
thumbnail_url: "mxc://privacytools.io/zBSerdKMhaXSIxfjzCmOnhXH" thumbnail_url: "mxc://privacytools.io/zBSerdKMhaXSIxfjzCmOnhXH"
}, },
msgtype: "m.image", msgtype: "m.image",
url: "mxc://privacytools.io/khPaFfeRyNdzlSttZraeAUre" url: "mxc://privacytools.io/khPaFfeRyNdzlSttZraeAUre"
}, },
event_id: "$aaa:matrix.org", event_id: "$aaa:matrix.org",
origin_server_ts: 1558470168199, origin_server_ts: 1558470168199,
unsigned: { unsigned: {
age: 143237861 age: 143237861
}, },
room_id: "!aaa:matrix.org" room_id: "!aaa:matrix.org"
} };
describe('media', function() { describe('media', function() {
describe('#parseEvent()', function() { describe('#parseEvent()', function() {
it('event without info', function() { it('event without info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate)) let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info = null mockEvent.content.info = null;
checkParsedEvent(mockEvent, { checkParsedEvent(mockEvent, {
w: 1000, w: 1000,
h: 1000, h: 1000,
method: 'scale' method: 'scale'
}) });
}), });
it('event without thumbnail', function() { it('event without thumbnail', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate)) let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info.thumbnail_url = null mockEvent.content.info.thumbnail_url = null;
mockEvent.content.info.thumbnail_info = null mockEvent.content.info.thumbnail_info = null;
checkParsedEvent(mockEvent, { checkParsedEvent(mockEvent, {
w: 268, w: 268,
h: 141, h: 141,
method: 'scale' method: 'scale'
}) });
}) });
it('event without thumbnail_info', function() { it('event without thumbnail_info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate)) let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info.thumbnail_url = null mockEvent.content.info.thumbnail_url = null;
checkParsedEvent(mockEvent, { checkParsedEvent(mockEvent, {
w: 268, w: 268,
h: 141, h: 141,
method: 'scale' method: 'scale'
}) });
}) });
}) });
}) });
function checkParsedEvent(mockEvent, expected) { function checkParsedEvent(mockEvent, expected) {
let media = mediaLib.parseEvent(client, mockEvent, 1000, 1000) let media = mediaLib.parseEvent(client, mockEvent, 1000, 1000);
let params = querystring.decode(urllib.parse(media.thumb).query) let params = querystring.decode(urllib.parse(media.thumb).query);
Object.keys(params).forEach((key) => { Object.keys(params).forEach((key) => {
assert.equal(expected[key], params[key]) assert.equal(expected[key], params[key]);
}) });
} }

10285
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save