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')
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')
"use strict";
const source = require('vinyl-source-stream')
const buffer = require('vinyl-buffer')
const sourcemaps = require('gulp-sourcemaps')
const gulp = require('gulp');
const sass = require('gulp-sass');
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 babelify = require('babelify')
const source = require('vinyl-source-stream');
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)
.pipe(sass())
.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"]))
})
const cssFiles = 'src/scss/**/*.?(s)css';
const assetsFiles = [ "src/assets/**/*" ];
gulp.task("clean", function(done) {
del.sync('build')
done()
})
del.sync('build');
done();
});
gulp.task("sass", function() {
return gulp.src(cssFiles)
.pipe(sass())
.pipe(concat('style.css'))
.pipe(gulp.dest('./build'))
})
return gulp.src(cssFiles)
.pipe(sass())
.pipe(concat('style.css'))
.pipe(gulp.dest('./build'));
});
gulp.task("assets", function() {
return gulp.src(["src/assets/**/*"])
.pipe(gulpIf('*.+(png|jpg|jpeg|gif|svg)',
cache(imagemin({
interlaced: true
}))
))
.pipe(gulp.dest('build'))
})
return gulp.src(assetsFiles)
/* NOTE: Currently disabled, causes an error:
[19:47:33] Error: Callback called multiple times
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)
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() {
return gulp.src(['src/app.js', "src/components/**/*"])
.pipe(babel({
presets: [
['@babel/env', {
modules: false
}]
]
}))
.pipe(gulp.dest('build'))
})
let b = browserify({
entries: 'src/app.js',
debug: false,
transform: [babelify.configure({
presets: ['@babel/preset-env', '@babel/preset-react']
})]
});
return b.bundle()
.pipe(source('src/app.js'))
.pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
.pipe(gulp.dest('build'));
});
gulp.task('js', function() {
let b = browserify({
entries: 'src/app.js',
debug: false,
transform: [babelify.configure({
presets: ['@babel/preset-env', '@babel/preset-react']
})]
})
return b.bundle()
.pipe(source('src/app.js'))
.pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
.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) {
done()
}]))
done();
}]));

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

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

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

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

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

@ -1,44 +1,35 @@
'use strict'
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
const Promise = require('bluebird')
const defaultValue = require('default-value')
'use strict';
const React = require('react');
const create = require('create-react-class');
const mediaLib = require('../../lib/media.js')
const Text = require('./text.js')
const mediaLib = require('../../lib/media.js');
let Event = create({
displayName: "m.image",
getInitialState: function() {
let event = this.props.event
if (event.content.url == undefined) {
return null
}
return mediaLib.parseEvent(this.props.client, event, 1000, 1000)
},
updateSize: function(e) {
console.log("image was loaded")
},
render: function() {
let event = this.props.event
if (this.state == null) {
return "malformed image event: " + event.content.body
}
return (
<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}}/>
</a>
</div>
)
}
})
displayName: "m.image",
getInitialState: function() {
let event = this.props.event;
if (event.content.url == undefined) {
return null;
}
return mediaLib.parseEvent(this.props.client, event, 1000, 1000);
},
render: function() {
let event = this.props.event;
if (this.state == null) {
return "malformed image event: " + event.content.body;
}
return (
<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}}/>
</a>
</div>
);
}
});
module.exports = Event;

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

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

@ -1,49 +1,27 @@
'use strict'
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
const jdenticon = require('jdenticon')
"use strict";
jdenticon.config = {
lightness: {
color: [0.58, 0.66],
grayscale: [0.30, 0.90]
},
saturation: {
color: 0.66,
grayscale: 0.00
},
backColor: "#00000000"
};
const React = require("react");
const create = require("create-react-class");
const generateJdenticon = require("../../lib/generate-jdenticon");
let User = create({
displayName: "user",
displayName: "user",
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 {
color: color
}
},
getInitialState: function() {
return {
/* FIXME: Cache this to speed it up */
color: generateJdenticon(this.props.user.userId).primaryColor()
};
},
render: function() {
return (
<div className="user" style={{color: this.state.color}}>
{this.props.user.displayName}
</div>
)
}
})
render: function() {
return (
<div className="user" style={{color: this.state.color}}>
{this.props.user.displayName}
</div>
);
}
});
module.exports = User
module.exports = User;

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

@ -1,46 +1,44 @@
'use strict'
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
'use strict';
let fileUpload = create({
displayName: "FileUpload",
const Promise = require("bluebird");
const React = require('react');
setFileRef: function(e) {
if (e != null) {
e.addEventListener('change', this.startUpload)
this.setState({
fileRef: e
})
}
},
const withElement = require("../lib/with-element");
const fileToDataUrl = require("../lib/file-to-data-url");
startUpload: function(e) {
Array.from(e.target.files).forEach((file) => {
if (file.type.startsWith("image/")) {
let reader = new FileReader()
reader.onloadend = () => {
let fileObject = {
file: file,
preview: reader.result
}
this.props.addUpload(fileObject)
}
reader.readAsDataURL(file)
} else {
this.props.addUpload({file: file, preview: "/icons/file.svg"})
}
})
},
module.exports = function FileUpload({ addUpload }) {
function handleChange(event) {
return Promise.map(Array.from(event.target.files), (file) => {
if (file.type.startsWith("image/")) {
return Promise.try(() => {
return fileToDataUrl(file);
}).then((url) => {
return addUpload({
file: file,
preview: url
});
});
} else {
return addUpload({
file: file,
preview: "/icons/file.svg"
});
}
});
}
render: function() {
return (
<div className="fileUpload">
<input type="file" id="fileUpload" multiple ref={this.setFileRef} />
<label htmlFor="fileUpload"><img src="/icons/file.svg"/></label>
</div>
)
}
})
let setFileRef = withElement((element) => {
element.addEventListener("change", handleChange);
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'
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
const Promise = require('bluebird')
const debounce = require('debounce')
'use strict';
const React = require('react');
const create = require('create-react-class');
const debounce = require('debounce');
let FilterList = create({
displayName: "FilterList",
displayName: "FilterList",
getInitialState: function() {
return {
selection: "room0",
filter: ""
}
},
getInitialState: function() {
return {
selection: "room0",
filter: ""
};
},
select: function(id) {
this.setState({selection: id, filter: ""})
this.state.inputRef.value = ""
this.props.callback(id)
},
select: function(id) {
this.setState({selection: id, filter: ""});
this.state.inputRef.value = "";
this.props.callback(id);
},
inputRef: function(ref) {
if (ref == null) {
return
}
this.setState({
inputRef: ref
})
ref.addEventListener("keyup", debounce(this.input, 20))
},
inputRef: function(ref) {
if (ref == null) {
return;
}
this.setState({
inputRef: ref
});
ref.addEventListener("keyup", debounce(this.input, 20));
},
input: function(e) {
this.setState({
filter: e.target.value.toUpperCase()
})
},
input: function(e) {
this.setState({
filter: e.target.value.toUpperCase()
});
},
render: function() {
let keys = Object.keys(this.props.items)
let items = keys.map((itemKey, id) => {
let item = this.props.items[itemKey]
let props = {
selected: this.state.selection == itemKey,
filter: this.state.filter,
content: item,
key: itemKey,
listId: itemKey,
select: this.select,
properties: this.props.properties
}
return React.createElement(this.props.element, props)
})
return <>
render: function() {
let keys = Object.keys(this.props.items);
let items = keys.map((itemKey) => {
let item = this.props.items[itemKey];
let props = {
selected: this.state.selection == itemKey,
filter: this.state.filter,
content: item,
key: itemKey,
listId: itemKey,
select: this.select,
properties: this.props.properties
};
return React.createElement(this.props.element, props);
});
return <>
<input className="filter" ref={this.inputRef} placeholder="Search"/>
<div className="list">
{items}
{items}
</div>
</>
}
})
</>;
}
});
module.exports = FilterList
module.exports = FilterList;

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

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

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

@ -1,117 +1,114 @@
'use strict'
const React = require('react')
const ReactDOM = require('react-dom')
const create = require('create-react-class')
const Promise = require('bluebird')
const debounce = require('debounce')
const jdenticon = require('jdenticon')
'use strict';
const React = require('react');
const create = require('create-react-class');
const jdenticon = require('jdenticon');
const FilterList = require('./filterList.js')
const FilterList = require('./filterList.js');
let RoomListItem = create({
displayName: "RoomListItem",
getInitialState: function() {
let room = this.props.content
let client = this.props.properties.client
let jdenticon = <svg id="avatar" ref={this.jdenticonRef}/>
let avatarUrl
let roomState = room.getLiveTimeline().getState('f')
let avatarState = roomState.getStateEvents('m.room.avatar')
if (avatarState.length > 0) {
let event = avatarState[avatarState.length-1].event
let hs = client.baseUrl
let media_mxc = event.content.url.slice(6)
let path = `/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale`
avatarUrl = {
hs: hs,
path: path
}
}
return {
filterName: room.name.toUpperCase(),
unread: Math.random() > 0.7,
avatarUrl: avatarUrl,
jdenticon: jdenticon,
tries: 0
}
},
jdenticonRef: function(ref) {
jdenticon.update(ref, this.props.content.roomId)
},
avatarFallback: function() {
// instead of falling back on jdenticon immediately, we can try
// a third-party homeserver's media repo
// this does come with trust issues, and is opt-in in settings
let fallbackMediaRepos = this.props.properties.options.fallbackMediaRepos
if (this.state.tries < fallbackMediaRepos.length) {
let avatarUrl = this.state.avatarUrl
avatarUrl.hs = fallbackMediaRepos[this.state.tries]
this.setState({
avatarUrl: avatarUrl,
tries: this.state.tries + 1
})
} else {
this.setState({avatarUrl: null, avatar: jdenticon})
}
},
setRef: function(ref) {
if (ref == null) {
return
}
this.setState({ref: ref})
ref.addEventListener("click", () => {this.props.select(this.props.listId)})
},
render: function() {
if (this.state.filterName.indexOf(this.props.filter) == -1) {
return null
}
let className = "roomListItem"
if (this.props.selected) {
className += " active"
}
if (this.state.unread) {
className += " unread"
}
return <div className={className} ref={this.setRef}>
{this.state.avatarUrl ?
<img id="avatar" src={`${this.state.avatarUrl.hs}${this.state.avatarUrl.path}`} onError={this.avatarFallback}></img>
:
this.state.jdenticon
}
<span id="name">{this.props.content.name}</span>
</div>
}
})
displayName: "RoomListItem",
getInitialState: function() {
let room = this.props.content;
let client = this.props.properties.client;
let jdenticon = <svg id="avatar" ref={this.jdenticonRef}/>;
let avatarUrl;
let roomState = room.getLiveTimeline().getState('f');
let avatarState = roomState.getStateEvents('m.room.avatar');
if (avatarState.length > 0) {
let event = avatarState[avatarState.length-1].event;
let hs = client.baseUrl;
let media_mxc = event.content.url.slice(6);
let path = `/_matrix/media/v1/thumbnail/${media_mxc}?width=128&height=128&method=scale`;
avatarUrl = {
hs: hs,
path: path
};
}
return {
filterName: room.name.toUpperCase(),
unread: Math.random() > 0.7,
avatarUrl: avatarUrl,
jdenticon: jdenticon,
tries: 0
};
},
jdenticonRef: function(ref) {
jdenticon.update(ref, this.props.content.roomId);
},
avatarFallback: function() {
// instead of falling back on jdenticon immediately, we can try
// a third-party homeserver's media repo
// this does come with trust issues, and is opt-in in settings
let fallbackMediaRepos = this.props.properties.options.fallbackMediaRepos;
if (this.state.tries < fallbackMediaRepos.length) {
let avatarUrl = this.state.avatarUrl;
avatarUrl.hs = fallbackMediaRepos[this.state.tries];
this.setState({
avatarUrl: avatarUrl,
tries: this.state.tries + 1
});
} else {
this.setState({avatarUrl: null, avatar: jdenticon});
}
},
setRef: function(ref) {
if (ref == null) {
return;
}
this.setState({ref: ref});
ref.addEventListener("click", () => {this.props.select(this.props.listId);});
},
render: function() {
if (this.state.filterName.indexOf(this.props.filter) == -1) {
return null;
}
let className = "roomListItem";
if (this.props.selected) {
className += " active";
}
if (this.state.unread) {
className += " unread";
}
return <div className={className} ref={this.setRef}>
{this.state.avatarUrl ?
<img id="avatar" src={`${this.state.avatarUrl.hs}${this.state.avatarUrl.path}`} onError={this.avatarFallback}></img>
:
this.state.jdenticon
}
<span id="name">{this.props.content.name}</span>
</div>;
}
});
let Sidebar = create({
displayName: "Sidebar",
displayName: "Sidebar",
getInitialState: function() {
return {
filter: ""
}
},
getInitialState: function() {
return {
filter: ""
};
},
setFilter: function(filter) {
this.setState({
filter: filter.toUpperCase()
})
},
setFilter: function(filter) {
this.setState({
filter: filter.toUpperCase()
});
},
render: function() {
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)}}/>
</div>
}
})
render: function() {
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);}}/>
</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
module.exports = {
parseEvent: function(client, event, maxHeight, maxWidth) {
if (event.content.msgtype == "m.image") {
let h = maxHeight
let w = maxWidth
let media_url = client.mxcUrlToHttp(event.content.url)
let thumb_url = event.content.url
if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) {
thumb_url = event.content.info.thumbnail_url
}
if (event.content.info.thumbnail_info != null) {
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
} else {
h = (event.content.info.h < maxHeight) ? event.content.info.h : h
w = (event.content.info.w < maxWidth) ? event.content.info.w : w
}
}
thumb_url = client.mxcUrlToHttp(thumb_url, w, h, 'scale', false)
return {
full: media_url,
thumb: thumb_url,
size: {h, w}
}
}
if (event.content.msgtype == "m.video") {
let thumb = null
let h = maxHeight
let w = maxWidth
if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) {
if (event.content.info.thumbnail_info != null) {
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
}
thumb = client.mxcUrlToHttp(event.content.thumbnail, w, h, 'scale', false)
}
}
return {
full: client.mxcUrlToHttp(event.content.url),
thumb: thumb,
size: {h, w}
}
}
}
}
parseEvent: function(client, event, maxHeight, maxWidth) {
if (event.content.msgtype == "m.image") {
let h = maxHeight;
let w = maxWidth;
let media_url = client.mxcUrlToHttp(event.content.url);
let thumb_url = event.content.url;
if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) {
thumb_url = event.content.info.thumbnail_url;
}
if (event.content.info.thumbnail_info != null) {
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;
} else {
h = (event.content.info.h < maxHeight) ? event.content.info.h : h;
w = (event.content.info.w < maxWidth) ? event.content.info.w : w;
}
}
thumb_url = client.mxcUrlToHttp(thumb_url, w, h, 'scale', false);
return {
full: media_url,
thumb: thumb_url,
size: {h, w}
};
}
if (event.content.msgtype == "m.video") {
let thumb = null;
let h = maxHeight;
let w = maxWidth;
if (event.content.info != null) {
if (event.content.info.thumbnail_url != null) {
if (event.content.info.thumbnail_info != null) {
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;
}
thumb = client.mxcUrlToHttp(event.content.thumbnail, w, h, 'scale', false);
}
}
return {
full: client.mxcUrlToHttp(event.content.url),
thumb: thumb,
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';
/*
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");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -14,7 +17,9 @@ limitations under the License.
const Promise = require('bluebird');
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}$/;
/**
@ -36,194 +41,220 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
* 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 = {
createThumbnail: function(element, inputWidth, inputHeight, mimeType) {
return new Promise(function(resolve, reject) {
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (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;
}
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
canvas.toBlob(function(thumbnail) {
resolve({
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
},
thumbnail: thumbnail,
});
}, mimeType);
});
},
/**
createThumbnail: function(element, inputWidth, inputHeight, mimeType) {
return Promise.try(() => {
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (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;
}
const canvas = Object.assign(document.createElement("canvas"), {
width: targetWidth,
height: targetHeight
});
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
return Promise.try(() => {
return canvasToBlob(canvas, mimeType);
}).then((thumbnail) => {
return {
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
},
thumbnail: thumbnail,
};
});
});
},
/**
* Load a file into a newly created image element.
*
* @param {File} file The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element.
*/
loadImageElement: function(imageFile) {
return new Promise(function(resolve, reject) {
// Load the file into an html element
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
img.src = objectUrl;
// Once ready, create a thumbnail
img.onload = function() {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = function(e) {
reject(e);
};
});
},
/**
loadImageElement: function(imageFile) {
return Promise.try(() => {
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
img.src = objectUrl;
return Promise.try(() => {
return awaitImageLoad(img);
}).then(() => {
URL.revokeObjectURL(objectUrl);
return img;
});
});
},
/**
* Load a file into a newly created video element.
*
* @param {File} file The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element.
*/
loadVideoElement: function(videoFile) {
return new Promise(function(resolve, reject) {
// Load the file into an html element
const video = document.createElement("video");
const reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
// Once ready, returns its size
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = function() {
video.width = video.videoWidth
video.height = video.videoHeight
resolve(video);
};
video.onerror = function(e) {
reject(e);
};
};
reader.onerror = function(e) {
reject(e);
};
reader.readAsDataURL(videoFile);
});
},
sanitize: function(html) {
return sanitize(html, this.sanitizeHtmlParams);
},
sanitizeHtmlParams: {
allowedTags: [
'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',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'mx-reply', 'mx-rainbow'
],
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
allowProtocolRelative: false,
transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'img': function(tagName, attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
//if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
//}
//attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
// attribs.src,
// attribs.width || 800,
// attribs.height || 600
//);
//return { tagName: tagName, attribs: attribs };
},
'code': function(tagName, 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-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper = {
'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
// $customAttributeKey: $cssAttributeKey
};
let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue &&
typeof customAttributeValue === 'string' &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey];
}
});
if (style) {
attribs.style = style;
}
return { tagName: tagName, attribs: attribs };
},
},
}
loadVideoElement: function(videoFile) {
return Promise.try(() => {
return fileToDataUrl(videoFile);
}).then((url) => {
const video = Object.assign(document.createElement("video"), {
src: url
});
return Promise.try(() => {
return awaitVideoLoad(video);
}).then((dimensions) => {
/* FIXME: Check whether this can be improved, it's a bit dirty to shoehorn the dimensions onto the video object like this */
return Object.assign(video, dimensions);
});
});
},
sanitize: function(html) {
return sanitize(html, this.sanitizeHtmlParams);
},
sanitizeHtmlParams: {
allowedTags: [
'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',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'mx-reply', 'mx-rainbow'
],
allowedAttributes: {
// custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'],
code: ['class'], // We don't actually allow all classes, we filter them in transformTags
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
allowProtocolRelative: false,
transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'img': function(tagName, _attribs) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
//if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}};
//}
//attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
// attribs.src,
// attribs.width || 800,
// attribs.height || 600
//);
//return { tagName: tagName, attribs: attribs };
},
'code': function(tagName, 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-');
});
attribs.class = classes.join(' ');
}
return {
tagName: tagName,
attribs: attribs,
};
},
'*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper = {
'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
// $customAttributeKey: $cssAttributeKey
};
let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue && 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')
let urllib = require('url')
let querystring = require('querystring')
"use strict";
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 = {
mxcUrlToHttp: function(url, w, h, method, allowDirectLinks) {
let hs = "https://chat.privacytools.io"
let mxc = url.slice(6)
if (w) {
return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}`
} else {
return `${hs}/_matrix/media/v1/download/${mxc}`
}
}
}
/* FIXME: Verify whether allowDirectLinks is used / expected to do anything by other code */
mxcUrlToHttp: function(url, w, h, method, _allowDirectLinks) {
let hs = "https://chat.privacytools.io";
let mxc = url.slice(6);
if (w) {
return `${hs}/_matrix/media/v1/thumbnail/${mxc}?w=${w}&h=${h}&method=${method}`;
} else {
return `${hs}/_matrix/media/v1/download/${mxc}`;
}
}
};
let mockEventTemplate = {
type: "m.room.message",
sender: "@f0x:privacytools.io",
content: {
body: "image.png",
info: {
size: 16692,
mimetype: "image/png",
thumbnail_info: {
w: 268,
h: 141,
mimetype: "image/png",
size: 16896
},
w: 268,
h: 141,
thumbnail_url: "mxc://privacytools.io/zBSerdKMhaXSIxfjzCmOnhXH"
},
msgtype: "m.image",
url: "mxc://privacytools.io/khPaFfeRyNdzlSttZraeAUre"
},
event_id: "$aaa:matrix.org",
origin_server_ts: 1558470168199,
unsigned: {
age: 143237861
},
room_id: "!aaa:matrix.org"
}
type: "m.room.message",
sender: "@f0x:privacytools.io",
content: {
body: "image.png",
info: {
size: 16692,
mimetype: "image/png",
thumbnail_info: {
w: 268,
h: 141,
mimetype: "image/png",
size: 16896
},
w: 268,
h: 141,
thumbnail_url: "mxc://privacytools.io/zBSerdKMhaXSIxfjzCmOnhXH"
},
msgtype: "m.image",
url: "mxc://privacytools.io/khPaFfeRyNdzlSttZraeAUre"
},
event_id: "$aaa:matrix.org",
origin_server_ts: 1558470168199,
unsigned: {
age: 143237861
},
room_id: "!aaa:matrix.org"
};
describe('media', function() {
describe('#parseEvent()', function() {
it('event without info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate))
mockEvent.content.info = null
describe('#parseEvent()', function() {
it('event without info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info = null;
checkParsedEvent(mockEvent, {
w: 1000,
h: 1000,
method: 'scale'
})
}),
it('event without thumbnail', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate))
mockEvent.content.info.thumbnail_url = null
mockEvent.content.info.thumbnail_info = null
checkParsedEvent(mockEvent, {
w: 268,
h: 141,
method: 'scale'
})
})
it('event without thumbnail_info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate))
mockEvent.content.info.thumbnail_url = null
checkParsedEvent(mockEvent, {
w: 268,
h: 141,
method: 'scale'
})
})
})
})
checkParsedEvent(mockEvent, {
w: 1000,
h: 1000,
method: 'scale'
});
});
it('event without thumbnail', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info.thumbnail_url = null;
mockEvent.content.info.thumbnail_info = null;
checkParsedEvent(mockEvent, {
w: 268,
h: 141,
method: 'scale'
});
});
it('event without thumbnail_info', function() {
let mockEvent = JSON.parse(JSON.stringify(mockEventTemplate));
mockEvent.content.info.thumbnail_url = null;
checkParsedEvent(mockEvent, {
w: 268,
h: 141,
method: 'scale'
});
});
});
});
function checkParsedEvent(mockEvent, expected) {
let media = mediaLib.parseEvent(client, mockEvent, 1000, 1000)
let params = querystring.decode(urllib.parse(media.thumb).query)
let media = mediaLib.parseEvent(client, mockEvent, 1000, 1000);
let params = querystring.decode(urllib.parse(media.thumb).query);
Object.keys(params).forEach((key) => {
assert.equal(expected[key], params[key])
})
Object.keys(params).forEach((key) => {
assert.equal(expected[key], params[key]);
});
}

10285
yarn.lock

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