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,72 +1,58 @@
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)
gulp.task('js', function() { at emitOne (events.js:116:13)
return gulp.src(['src/app.js', "src/components/**/*"]) at DestroyableTransform.emit (events.js:211:7)
.pipe(babel({ at /home/sven/projects/iris/node_modules/through2-concurrent/through2-concurrent.js:41:14
presets: [ at imagemin.buffer.then.catch.error (/home/sven/projects/iris/node_modules/gulp-imagemin/index.js:98:5)
['@babel/env', { */
modules: false // .pipe(gulpIf('*.+(png|jpg|jpeg|gif|svg)',
}] // cache(imagemin({
] // interlaced: true
})) // }))
.pipe(gulp.dest('build')) // ))
}) .pipe(gulp.dest('build'));
});
gulp.task('js', function() { gulp.task('js', function() {
let b = browserify({ let b = browserify({
@ -75,14 +61,28 @@ gulp.task('js', function() {
transform: [babelify.configure({ transform: [babelify.configure({
presets: ['@babel/preset-env', '@babel/preset-react'] presets: ['@babel/preset-env', '@babel/preset-react']
})] })]
}) });
return b.bundle() return b.bundle()
.pipe(source('src/app.js')) .pipe(source('src/app.js'))
.pipe(buffer()) .pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true })) .pipe(sourcemaps.init({ loadMaps: true }))
.pipe(gulp.dest('build')) .pipe(gulp.dest('build'));
}) });
gulp.task('watch', gulp.series(["clean", "assets", "sass", 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.watch(assetsFiles, gulp.series(["assets"]));
}]));
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 Login = require('./components/Login.js');
const Sidebar = require('./components/sidebar.js') const Chat = require('./components/chat.js');
const Login = require('./components/Login.js')
const Chat = require('./components/chat.js')
// Things that will get settings: // Things that will get settings:
// colorscheme // colorscheme
@ -24,27 +22,27 @@ let App = create({
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,
@ -54,48 +52,57 @@ let App = create({
this.setState({ this.setState({
client: client client: client
}) });
this.startClient(client)
this.startClient(client);
},
updateRooms: function (client) {
let rooms = {};
client.getRooms().forEach((room) => {
rooms[room.roomId] = room;
});
this.setState({rooms: rooms});
}, },
startClient: function(client) { startClient: function(client) {
console.log(client) console.log(client);
client.on("sync", (state, prevState, data) => {
client.on("sync", (state, _prevState, _data) => {
if (state == "ERROR") { if (state == "ERROR") {
/* FIXME: Implement? */
} else if (state == "SYNCING") { } else if (state == "SYNCING") {
let rooms = {} this.updateRooms(client);
client.getRooms().forEach((room) => {
rooms[room.roomId] = room
})
this.setState({rooms: rooms})
} else if (state == "PREPARED") { } else if (state == "PREPARED") {
/* FIXME: Implement? */
} }
}) });
client.on("Room.localEchoUpdated", (event) => {
let rooms = {} client.on("Room.localEchoUpdated", (_event) => {
client.getRooms().forEach((room) => { this.updateRooms(client);
rooms[room.roomId] = room });
})
this.setState({rooms: rooms}) client.startClient();
})
client.startClient()
}, },
render: function() { render: function() {
if (this.state.client == undefined) { if (this.state.client == undefined) {
//Login screen //Login screen
return <Login callback={this.loginCallback}/> return <Login callback={this.loginCallback}/>;
} } else {
return ( return (
<> <>
<Sidebar options={this.state.options} client={this.state.client} rooms={this.state.rooms} selectRoom={(roomId) => {this.setState({roomId: roomId})}}/> <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}/> <Chat client={this.state.client} roomId={this.state.roomId}/>
</> </>
) );
}
} }
}) });
ReactDOM.render( ReactDOM.render(
<App />, <App />,
document.getElementById('root') document.getElementById('root')
) );

@ -1,11 +1,11 @@
'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",
@ -23,109 +23,124 @@ let login = create({
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) { if (this.state.hs.valid) {
return this.doLogin() return this.doLogin();
} }
let parts = this.state.formState.user.split(':') let parts = this.state.formState.user.split(':');
if (parts.length != 2) { if (parts.length != 2) {
return this.setState({error: "Please enter a full mxid, like username:homeserver.tld"}) return this.setState({error: "Please enter a full mxid, like username:homeserver.tld"});
} }
let hostname = urllib.parse("https://" + parts[1]) let hostname = urllib.parse("https://" + parts[1]);
getApiServer(hostname).then((hs) => {
console.log("Using API server", hs) return Promise.try(() => {
let formState = this.state.formState return getApiServer(hostname);
formState.user = parts[0] }).then((homeserverUrl) => {
formState.hs = hs console.log("Using API server", homeserverUrl);
let hsState = Object.assign(this.state.hs, {valid: true})
this.setState({apiUrl: hs, formState: formState, hs: hsState}) this.setState({
this.doLogin() apiUrl: homeserverUrl,
apiRequest: createApiRequester(homeserverUrl),
formState: Object.assign(this.state.formState, {
user: parts[0],
hs: homeserverUrl
}),
hs: Object.assign(this.state.hs, {
valid: true
})
});
return this.doLogin();
}).catch((error) => { }).catch((error) => {
console.log("ERROR fetching homeserver url", error) /* FIXME: Error filtering */
let hsState = Object.assign(this.state.hs, {error: error, valid: false, prompt: true}) console.log("ERROR fetching homeserver url", error);
this.setState({hs: hsState})
this.setState({
hs: Object.assign(this.state.hs, {
error: error,
valid: false,
prompt: true
}) })
});
});
});
}, },
doLogin: function() { doLogin: function() {
console.log("Logging in") return Promise.try(() => {
let user = this.state.formState.user.replace('@', '') console.log("Logging in");
let password = this.state.formState.pass let user = this.state.formState.user.replace('@', '');
let hs = this.state.apiUrl let password = this.state.formState.pass;
let homeserverUrl = this.state.apiUrl;
let data = { return Promise.try(() => {
return this.state.apiRequest("/_matrix/client/r0/login", {
user: user, user: user,
password: password, password: password,
type: "m.login.password", type: "m.login.password",
initial_device_display_name: "Neo v4", initial_device_display_name: "Neo v4",
}; });
}).then((responseJson) => {
console.log("got access token", responseJson);
let url = hs + "/_matrix/client/r0/login" this.setState({ json: responseJson });
fetch(url, {
body: JSON.stringify(data),
headers: {
'content-type': 'application/json'
},
method: 'POST',
}).then((response) => response.json())
.then((responseJson) => {
console.log("got access token", responseJson)
this.setState({json: responseJson})
if(responseJson.access_token != undefined) { if(responseJson.access_token != undefined) {
this.props.callback(responseJson.user_id, responseJson.access_token, hs) this.props.callback(responseJson.user_id, responseJson.access_token, homeserverUrl);
} else { } else {
this.setState({error: responseJson.error}) this.setState({ error: responseJson.error });
} }
}) }).catch((error) => {
.catch((error) => { /* FIXME: Why are errors being swallowed here? */
console.error(url, error); console.error(error);
});
}); });
}, },
handleUserChange: function(e) { handleUserChange: function(e) {
let formState = this.state.formState let formState = this.state.formState;
let user = e.target.value let user = e.target.value;
formState.user = e.target.value formState.user = e.target.value;
let parts = user.split(':') let parts = user.split(':');
if (parts.length == 2) { if (parts.length == 2) {
formState.hs = parts[1] formState.hs = parts[1];
let hsState = Object.assign(this.state.hs, {error: null, valid: false}) let hsState = Object.assign(this.state.hs, {error: null, valid: false});
this.setState({hs: hsState}) this.setState({hs: hsState});
} }
this.setState({formState: formState}) this.setState({formState: formState});
}, },
handlePassChange: function(e) { handlePassChange: function(e) {
let formState = this.state.formState let formState = this.state.formState;
formState.pass = e.target.value formState.pass = e.target.value;
this.setState({formState: formState}) this.setState({formState: formState});
}, },
handleHsChange: function(e) { handleHomeserverChange: function(e) {
let formState = this.state.formState let formState = this.state.formState;
formState.hs = e.target.value formState.hs = e.target.value;
this.setState({formState: formState}) this.setState({formState: formState});
this.setState({hs: {error: null, valid: false, prompt: true, changed: true}}) this.setState({hs: {error: null, valid: false, prompt: true, changed: true}});
}, },
render: function() { render: function() {
let hsState = "inactive" let hsState = "inactive";
if (this.state.hs.prompt) { if (this.state.hs.prompt) {
hsState = "active" hsState = "active";
} }
if (this.state.hs.error != null) { if (this.state.hs.error != null) {
hsState = "error" hsState = "error";
} }
if (this.state.hs.valid) { if (this.state.hs.valid) {
hsState = "validated" hsState = "validated";
} }
return ( return (
@ -142,7 +157,7 @@ let login = create({
<label htmlFor="hs" className={hsState}>Homeserver: </label> <label htmlFor="hs" className={hsState}>Homeserver: </label>
{this.state.hs.prompt ? ( {this.state.hs.prompt ? (
<> <>
<input type="text" id="hs" value={this.state.formState["hs"]} onChange={this.handleHsChange}/> <input type="text" id="hs" value={this.state.formState["hs"]} onChange={this.handleHomeserverChange}/>
</> </>
) : ( ) : (
<span id="hs">{this.state.formState["hs"]}</span> <span id="hs">{this.state.formState["hs"]}</span>
@ -151,78 +166,95 @@ let login = create({
<button onClick={()=>this.login()}>Log in</button> <button onClick={()=>this.login()}>Log in</button>
</div> </div>
</div> </div>
) );
} }
}) });
function getApiServer(parsedUrl) {
return Promise.try(() => {
console.log("Checking for api server from mxid", urllib.format(parsedUrl));
function getApiServer(hostname) { return checkApi(parsedUrl);
return new Promise((resolve, reject) => { }).then(() => {
console.log("Checking for api server from mxid", urllib.format(hostname))
checkApi(hostname).then(() => {
// Hostname is a valid api server // Hostname is a valid api server
hostname.pathname = "" return buildUrl(parsedUrl, "");
resolve(urllib.format(hostname))
}).catch(() => { }).catch(() => {
console.log("trying .well-known") /* FIXME: Error filtering */
tryWellKnown(hostname).then((hostname) => { console.log("trying .well-known");
console.log("got .well-known host", hostname)
resolve(hostname) return Promise.try(() => {
}).catch((err) => { return tryWellKnown(parsedUrl);
reject("Fatal error trying to get API host") }).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) => {
return Promise.try(() => {
return fetch(versionUrl);
}).then((response) => {
if (response.status != 200) { if (response.status != 200) {
console.log("Invalid homeserver url", versionUrl) console.log("Invalid homeserver url", versionUrl);
return reject()
/* FIXME: Error types */
throw new Error("Invalid homeserver URL");
} }
resolve() });
}).catch((err) => { });
reject(err)
})
})
} }
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(() => {
return fetch(wellKnownUrl);
}).tap((response) => {
if (response.status != 200) { if (response.status != 200) {
console.log("no well-known in use") console.log("no well-known in use");
reject("No homeserver found")
/* FIXME: Error type */
throw new Error("No homeserver found");
} }
return response }).catch((_error) => {
}).catch((error) => { /* FIXME: Error chaining */
reject("can't fetch .well-known") throw new Error("can't fetch .well-known");
}) }).then((response) => {
.then((response) => response.json()) return response.json();
.then((json) => { }).then((json) => {
console.log("Parsed json", json) console.log("Parsed json", json);
if (json['m.homeserver'] != undefined && json['m.homeserver'].base_url != undefined) {
resolve(json['m.homeserver'].base_url) if (json['m.homeserver'] != null && json['m.homeserver'].base_url != null) {
return json['m.homeserver'].base_url;
} else {
/* FIXME: Error type */
throw new Error("No homeserver specified in .well-known");
} }
}) }).catch((err) => {
.catch((err) => { /* FIXME: Error filtering? */
console.log("Error in json", err) console.log("Error in json", err);
reject("Error while parsing .well-known")
}) /* 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,67 +1,56 @@
'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.type == "m.room.message") {
plain = this.content.body
if (this.content.format == "org.matrix.custom.html") { if (this.content.format == "org.matrix.custom.html") {
plain = sanitize(this.content.formatted_body, {allowedTags: []}) return sanitize(this.content.formatted_body, {allowedTags: []});
} } else {
return this.content.body;
} }
if (this.type == "m.room.member") { } else if (this.type == "m.room.member") {
if (this.content.membership == "invite") { if (this.content.membership == "invite") {
plain = `${this.sender} invited ${this.state_key}` return `${this.sender} invited ${this.state_key}`;
} else if (this.content.membership == "join") { } else if (this.content.membership == "join") {
plain = `${this.state_key} joined the room` return `${this.state_key} joined the room`;
} else if (this.content.membership == "leave") { } else if (this.content.membership == "leave") {
plain = `${this.state_key} left the room` return `${this.state_key} left the room`;
} else if (this.content.membership == "kick") { } else if (this.content.membership == "kick") {
plain = `${this.sender} kicked ${this.state_key}` return `${this.sender} kicked ${this.state_key}`;
} else if (this.content.membership == "ban") { } else if (this.content.membership == "ban") {
plain = `${this.sender} banned ${this.state_key}` return `${this.sender} banned ${this.state_key}`;
} else {
return "unknown member event";
} }
} } else if (this.type == "m.room.avatar") {
if (this.type == "m.room.avatar") {
if (this.content.url.length > 0) { if (this.content.url.length > 0) {
plain = `${this.sender} changed the room avatar` return `${this.sender} changed the room avatar`;
}
} }
if (this.type == "m.room.name") { } else if (this.type == "m.room.name") {
return `${this.sender} changed the room name to ${this.content.name}` return `${this.sender} changed the room name to ${this.content.name}`;
} else {
return "unknown event";
} }
return plain
} }
} };
let chat = create({ let chat = create({
displayName: "Chat", displayName: "Chat",
@ -70,101 +59,85 @@ let chat = create({
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;
} else {
return null;
} }
return null
}, },
componentDidUpdate(prevProps, prevState, snapshot) { componentDidUpdate(prevProps, prevState, snapshot) {
let ref = this.state.ref let ref = this.state.ref;
if (ref == null) {return}
if (snapshot) { // scroll to bottom if (ref != null && snapshot) {
ref.scrollTop = (ref.scrollHeight - ref.offsetHeight) // scroll to bottom
ref.scrollTop = (ref.scrollHeight - ref.offsetHeight);
} }
}, },
setRef: function(ref) { setRef: function(ref) {
if (ref != null) { if (ref != null) {
this.setState({ref: ref}) this.setState({ ref: ref });
} }
}, },
onReplyClick: function(e) { onReplyClick: function(e) {
this.setState({replyEvent: e}) this.setState({ replyEvent: e });
}, },
paginateBackwards: function() { paginateBackwards: function() {
if (this.state.loading) { if (!this.state.loading) {
return let client = this.props.client;
} let timeline = client.getRoom(this.props.roomId).getLiveTimeline();
let client = this.props.client
client.paginateEventTimeline(client.getRoom(this.props.roomId).getLiveTimeline(), {backwards: true}).then(() => {
this.setState({loading: false})
})
this.setState({loading: true})
},
render: function() { this.setState({loading: true});
let client = this.props.client
let empty = (
<div className="main">
</div>
)
if (this.props.roomId == undefined) {
//empty screen
return empty
}
let room = client.getRoom(this.props.roomId) return Promise.try(() => {
if (room == null) { return client.paginateEventTimeline(timeline, {backwards: true});
return empty }).then(() => {
} this.setState({loading: false});
});
let messageGroups = {
current: [],
groups: [],
sender: "",
type: ""
} }
},
// if the sender is the same, add it to the 'current' messageGroup, if not, render: function() {
// push the old one to 'groups' and start with a new array. let client = this.props.client;
let liveTimeline = room.getLiveTimeline() let empty = <div className="main" />;
let liveTimelineEvents = liveTimeline.getEvents()
let events = [] if (this.props.roomId == null) {
if (liveTimelineEvents.length > 0) { //empty screen
liveTimelineEvents.forEach((MatrixEvent) => { return empty;
let event = MatrixEvent.event; } else {
event = Object.assign(event, eventFunctions) let room = client.getRoom(this.props.roomId);
if (event.sender == null) { // localecho messages if (room == null) {
event.sender = event.user_id return empty;
event.local = true } else {
} let liveTimeline = room.getLiveTimeline();
if (event.sender != messageGroups.sender || event.type != messageGroups.type) { let liveTimelineEvents = liveTimeline.getEvents();
messageGroups.sender = event.sender
messageGroups.type = event.type let events = liveTimelineEvents.map((item) => {
if (messageGroups.current.length != 0) { let event = item.event;
messageGroups.groups.push(messageGroups.current)
} return Object.assign(
messageGroups.current = [] event,
} eventFunctions,
messageGroups.current.push(event) (event.sender == null)
}) /* Whether this event is a local echo */
messageGroups.groups.push(messageGroups.current) ? { local: true, sender: event.user_id }
: null
);
});
let eventGroups = groupEvents(events);
events = messageGroups.groups.map((events, id) => {
return <EventGroup key={`${this.props.roomId}-${events[0].event_id}`} events={events} client={this.props.client} room={room} onReplyClick={this.onReplyClick}/>
})
}
//TODO: replace with something that only renders events in view //TODO: replace with something that only renders events in view
return ( return (
<div className="main"> <div className="main">
@ -177,51 +150,52 @@ let chat = create({
<span>load older messages</span> <span>load older messages</span>
} }
</div> </div>
{events} {(eventGroups.map((group) => {
return <EventGroup key={`${this.props.roomId}-${group.events[0].event_id}`} events={group.events} client={this.props.client} room={room} onReplyClick={this.onReplyClick}/>;
}))}
</div> </div>
</div> </div>
<Input client={client} roomId={this.props.roomId} replyEvent={this.state.replyEvent} onReplyClick={this.onReplyClick}/> <Input client={client} roomId={this.props.roomId} replyEvent={this.state.replyEvent} onReplyClick={this.onReplyClick}/>
</div> </div>
) );
}
}
} }
}) });
let EventGroup = create({ function EventGroup({ events, room, client, onReplyClick }) {
displayName: "EventGroup", let setAvatarRef = withElement((element) => {
generateJdenticon(user.userId).update(element);
});
getInitialState: function() { let user = client.getUser(events[0].sender);
let user = this.props.client.getUser(this.props.events[0].sender)
let avatar = <svg id="avatar" ref={this.avatarRef} />
let avatar = expression(() => {
if (user.avatarUrl != null) { if (user.avatarUrl != null) {
let hs = this.props.client.baseUrl let url = generateThumbnailUrl({
let media_mxc = user.avatarUrl.slice(6) homeserver: client.baseUrl,
let url = `${hs}/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale` mxc: parseMXC(user.avatarUrl),
avatar = <img id="avatar" src={url}/> width: 128,
} height: 128
});
return { return <img id="avatar" src={url} />;
user: user, } else {
avatar: avatar return <svg id="avatar" ref={setAvatarRef} />;
} }
}, });
avatarRef: function(ref) {
jdenticon.update(ref, this.state.user.userId)
},
render: function() { return (
let events = this.props.events.map((event, key) => { <div className="eventGroup">
return <Event event={event} key={key} client={this.props.client} room={this.props.room} onReplyClick={this.props.onReplyClick}/> {avatar}
})
return <div className="eventGroup">
{this.state.avatar}
<div className="col"> <div className="col">
<User user={this.state.user}/> <User user={user}/>
{events} {events.map((event, key) => {
return <Event event={event} key={key} client={client} room={room} onReplyClick={onReplyClick}/>;
})}
</div> </div>
</div> </div>
} );
}) }
module.exports = chat module.exports = chat;

@ -1,54 +1,53 @@
'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 = (
@ -56,60 +55,60 @@ let Event = create({
<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,34 +1,25 @@
'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) {
console.log("image was loaded")
}, },
render: function() { render: function() {
let event = this.props.event let event = this.props.event;
if (this.state == null) { if (this.state == null) {
return "malformed image event: " + event.content.body return "malformed image event: " + event.content.body;
} }
return ( return (
@ -37,8 +28,8 @@ let Event = create({
<img src={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}/> <img src={this.state.thumb} style={{maxHeight: this.state.size.h, maxWidth: this.state.size.w}}/>
</a> </a>
</div> </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,40 +1,18 @@
'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)
let match = icon.match(/#([a-f0-9]{6})/g)
let color = '#ff0000'
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 { return {
color: color /* FIXME: Cache this to speed it up */
} color: generateJdenticon(this.props.user.userId).primaryColor()
};
}, },
render: function() { render: function() {
@ -42,8 +20,8 @@ let User = create({
<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,30 +1,25 @@
'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 (
@ -33,8 +28,8 @@ let Event = create({
<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) {
return Promise.map(Array.from(event.target.files), (file) => {
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
let reader = new FileReader() return Promise.try(() => {
reader.onloadend = () => { return fileToDataUrl(file);
let fileObject = { }).then((url) => {
return addUpload({
file: file, file: file,
preview: reader.result preview: url
} });
this.props.addUpload(fileObject) });
}
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) => {
element.addEventListener("change", handleChange);
return function() {
element.removeEventListener("change", handleChange);
};
});
return ( return (
<div className="fileUpload"> <div className="fileUpload">
<input type="file" id="fileUpload" multiple ref={this.setFileRef} /> <input type="file" id="fileUpload" multiple ref={setFileRef} />
<label htmlFor="fileUpload"><img src="/icons/file.svg"/></label> <label htmlFor="fileUpload"><img src="/icons/file.svg"/></label>
</div> </div>
) );
} };
})
module.exports = fileUpload

@ -1,9 +1,7 @@
'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",
@ -12,35 +10,35 @@ let FilterList = create({
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,
@ -49,16 +47,16 @@ let FilterList = create({
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,13 +1,12 @@
'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",
@ -15,7 +14,7 @@ let input = create({
getInitialState: function() { getInitialState: function() {
return { return {
uploads: [] uploads: []
} };
}, },
setRef: function(ref) { setRef: function(ref) {
@ -24,30 +23,30 @@ let input = create({
// 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) {
@ -66,119 +65,123 @@ let input = create({
}, },
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) => {
return Promise.map(uploads, (upload) => {
let fileUploadPromise = client.uploadContent(upload.file, let fileUploadPromise = client.uploadContent(upload.file,
{onlyContentUri: false}).then((response) => { {onlyContentUri: false}).then((response) => {
return response.content_uri return response.content_uri;
}) });
let mimeType = upload.file.type let mimeType = upload.file.type;
let eventType = "m.file" let eventType = "m.file";
let additionalPromise let additionalPromise;
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) {
function elementToThumbnail(element) { function elementToThumbnail(element) {
return new Promise((resolve, reject) => { return Promise.try(() => {
riot.createThumbnail(element, return riot.createThumbnail(element,
element.width, element.width,
element.height, element.height,
thumbnailType thumbnailType
) );
.catch((error) => { }).catch((error) => {
console.error("neo: error getting thumbnail", error) console.error("neo: error getting thumbnail", error);
reject()
}) throw error;
.then((thumbResult) => { }).then((thumbResult) => {
return client.uploadContent(thumbResult.thumbnail, {onlyContentUri: false}) return client.uploadContent(thumbResult.thumbnail, {onlyContentUri: false});
}).then((response) => { }).then((response) => {
return resolve({ return {
thumbnail_url: response.content_uri, thumbnail_url: response.content_uri,
thumbnail_info: { thumbnail_info: {
mimetype: thumbnailType mimetype: thumbnailType
} }
}) };
}) });
})
} }
if (mimeType.startsWith("image/")) { if (mimeType.startsWith("image/")) {
eventType = "m.image" eventType = "m.image";
additionalPromise = riot.loadImageElement(upload.file) additionalPromise = riot.loadImageElement(upload.file)
.then((element) => {return elementToThumbnail(element)}) .then((element) => {return elementToThumbnail(element);});
} else if (mimeType.startsWith("video/")) { } else if (mimeType.startsWith("video/")) {
eventType = "m.video" eventType = "m.video";
additionalPromise = riot.loadVideoElement(upload.file) additionalPromise = riot.loadVideoElement(upload.file)
.then((element) => {return elementToThumbnail(element)}) .then((element) => {return elementToThumbnail(element);});
} }
// create and upload thumbnail // create and upload thumbnail
let thumbnailType = "image/png" let thumbnailType = "image/png";
if (mimeType == "image/jpeg") { if (mimeType == "image/jpeg") {
thumbnailType = mimeType thumbnailType = mimeType;
} }
} else if (mimeType.startsWith("audio/")) { } else if (mimeType.startsWith("audio/")) {
eventType = "m.audio" eventType = "m.audio";
} else { } else {
// m.file // m.file
} }
Promise.all([fileUploadPromise, additionalPromise]).then((result) => {
console.log(result) return Promise.all([fileUploadPromise, additionalPromise]).then((result) => {
console.log(result);
let info = { let info = {
mimetype: mimeType mimetype: mimeType
} };
if (result[1] != undefined) { if (result[1] != undefined) {
info = Object.assign(info, result[1]) info = Object.assign(info, result[1]);
} }
client.sendEvent(this.props.roomId, "m.room.message", {
return client.sendEvent(this.props.roomId, "m.room.message", {
body: upload.file.name, body: upload.file.name,
msgtype: eventType, msgtype: eventType,
info: info, info: info,
url: result[0] url: result[0]
}) });
}) });
}) });
}, },
sendPlain: function(string) { sendPlain: function(string) {
let content = { let content = {
body: string, body: string,
msgtype: "m.text" msgtype: "m.text"
} };
content = this.sendReply(content) content = this.sendReply(content);
this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, res) => { /* FIXME: Promisify */
this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, _res) => {
if (err != null) { if (err != null) {
console.log(err) console.log(err);
} }
}) });
}, },
sendHTML: function(html) { sendHTML: function(html) {
@ -187,13 +190,14 @@ let input = create({
formatted_body: html, formatted_body: html,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
msgtype: "m.text" msgtype: "m.text"
} };
content = this.sendReply(content) content = this.sendReply(content);
this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, res) => { /* FIXME: Promisify */
console.log(err) this.props.client.sendEvent(this.props.roomId, "m.room.message", content, (err, _res) => {
}) console.log(err);
});
}, },
sendReply: function(content) { sendReply: function(content) {
@ -202,10 +206,10 @@ let input = create({
'm.in_reply_to': { 'm.in_reply_to': {
event_id: this.props.replyEvent.event_id event_id: this.props.replyEvent.event_id
} }
};
this.props.onReplyClick();
} }
this.props.onReplyClick() return content;
}
return content
}, },
render: function() { render: function() {
@ -223,7 +227,7 @@ let input = create({
<img src={upload.preview}/> <img src={upload.preview}/>
<span onClick={() => this.removeUpload(key)}>X</span> <span onClick={() => this.removeUpload(key)}>X</span>
</div> </div>
) );
})} })}
</div> </div>
} }
@ -231,26 +235,26 @@ let input = create({
<textarea ref={this.setRef} rows="1" spellCheck="false" placeholder="unencrypted message"></textarea> <textarea ref={this.setRef} rows="1" spellCheck="false" placeholder="unencrypted message"></textarea>
<FileUpload addUpload={this.addUpload}/> <FileUpload addUpload={this.addUpload}/>
</div> </div>
</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) {
@ -274,4 +278,4 @@ function rainbowTransform(text) {
return rainbow; return rainbow;
} }
module.exports = input module.exports = input;

@ -1,7 +1,6 @@
'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",
@ -13,8 +12,8 @@ let Loading = create({
<div className="bounce2"/> <div className="bounce2"/>
<div className="bounce3"/> <div className="bounce3"/>
</div> </div>
) );
} }
}) });
module.exports = Loading module.exports = Loading;

@ -1,33 +1,30 @@
'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 {
@ -36,49 +33,49 @@ let RoomListItem = create({
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 ?
@ -87,9 +84,9 @@ let RoomListItem = create({
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",
@ -97,21 +94,21 @@ let Sidebar = create({
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,29 +41,70 @@ 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) { if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT; targetHeight = MAX_HEIGHT;
} }
if (targetWidth > MAX_WIDTH) { if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH; targetWidth = MAX_WIDTH;
} }
const canvas = document.createElement("canvas"); const canvas = Object.assign(document.createElement("canvas"), {
canvas.width = targetWidth; width: targetWidth,
canvas.height = targetHeight; height: targetHeight
});
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
canvas.toBlob(function(thumbnail) {
resolve({ return Promise.try(() => {
return canvasToBlob(canvas, mimeType);
}).then((thumbnail) => {
return {
info: { info: {
thumbnail_info: { thumbnail_info: {
w: targetWidth, w: targetWidth,
@ -70,8 +116,8 @@ module.exports = {
h: inputHeight, h: inputHeight,
}, },
thumbnail: thumbnail, thumbnail: thumbnail,
};
}); });
}, mimeType);
}); });
}, },
@ -82,20 +128,18 @@ module.exports = {
* @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;
// Once ready, create a thumbnail return Promise.try(() => {
img.onload = function() { return awaitImageLoad(img);
}).then(() => {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
resolve(img);
}; return img;
img.onerror = function(e) { });
reject(e);
};
}); });
}, },
@ -106,29 +150,19 @@ module.exports = {
* @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;
// Once ready, returns its size return Promise.try(() => {
// Wait until we have enough data to thumbnail the first frame. return awaitVideoLoad(video);
video.onloadeddata = function() { }).then((dimensions) => {
video.width = video.videoWidth /* FIXME: Check whether this can be improved, it's a bit dirty to shoehorn the dimensions onto the video object like this */
video.height = video.videoHeight return Object.assign(video, dimensions);
resolve(video); });
};
video.onerror = function(e) {
reject(e);
};
};
reader.onerror = function(e) {
reject(e);
};
reader.readAsDataURL(videoFile);
}); });
}, },
@ -163,7 +197,7 @@ module.exports = {
transformTags: { // custom to matrix transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
'img': function(tagName, attribs) { 'img': function(tagName, _attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
@ -209,10 +243,7 @@ module.exports = {
Object.keys(customCSSMapper).forEach((customAttributeKey) => { Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey]; const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey]; const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue && if (customAttributeValue && typeof customAttributeValue === 'string' && COLOR_REGEX.test(customAttributeValue)) {
typeof customAttributeValue === 'string' &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";"; style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey]; delete attribs[customAttributeKey];
} }

@ -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,20 +1,25 @@
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";
let mxc = url.slice(6);
if (w) { if (w) {
return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}` return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}`;
} else { } else {
return `${hs}/_matrix/media/v1/download/${mxc}` return `${hs}/_matrix/media/v1/download/${mxc}`;
} }
} }
} };
let mockEventTemplate = { let mockEventTemplate = {
type: "m.room.message", type: "m.room.message",
@ -43,47 +48,47 @@ let mockEventTemplate = {
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