some rearrangements

master
Marc Wäckerlin 8 years ago
parent 94b952fbcc
commit cc4f5d6619
  1. 1698
      nodejs/public/javascripts/safechat.js
  2. 0
      nodejs/public/sounds/beep.mp3
  3. 27
      nodejs/views/index.ejs

@ -37,870 +37,1026 @@
// 1 2 3 4 5 6 7 8
// 45678901234567890123456789012345678901234567890123456789012345678901234567890
function SafeChatClient(success, notice, error) {
function SafeChat() {
/// Cache Client's Key from local Strorage
var k = null;
function browserSupported() {
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB
window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction
window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange
return window.indexedDB && window.crypto.getRandomValues && Storage
/// Create UID from a name by appending an E-Mail
function uid(name) {
return name+' <'+name+'@'+hostname+'>'
}
/// Get User Key
/** @internal key ist cached in k
@return key */
function key() {
if (k) return k
if (typeof localStorage.key == 'undefined') return null
return k = openpgp.key.readArmored(localStorage.key)
}
/// @class Crypto cryptographic functions
/** @param view is of class SafeChat.View */
function Crypto(view) {
/// cache client's key from local strorage
var k = null
/// detect hosstname, default to safechat.ch
var hostname = window.location.hostname!='localhost'?window.location.hostname:'safechat.ch'
/// get user key
/** @internal key ist cached in k
@return key */
function key() {
if (k) return k
if (typeof localStorage.key == 'undefined') return null
return k = openpgp.key.readArmored(localStorage.key)
}
/// get own user name
/** get user name as user id of first public key */
function user() {
if (k || key()) return k.pub.keys[0].getUserIds()[0]
return null
}
/// create New User
function createuser(user, email, pwd) {
view.notice("generating keys")
openpgp.generateKey({
numBits: 4096,
userIds: [{name: user, email: email}],
passphrase: pwd
}).then(function(keyPair) {
view.success("keys generated")
localStorage.key = keyPair.privateKeyArmored
k = keyPair.key
}).catch(function(e) {
console.log(e)
view.fatal("generating key pairs failed")
})
}
/// open private key with password
/** @return @c true if password matches */
function password(pwd) {
return (k || keys()) && k.keys[0].decrypt(pwd)
}
/// Encrypt Message
function encrypt(message, targetkeys, done, failed) {
if (!k) return false
openpgp.encrypt({
publicKeys: targetkeys.keys.concat(k.keys),
privateKeys: k,
data: message,
armor: false
}).then(done).else(failed)
return true
}
/// Decrypt Message
function decrypt(message, sourcekeys, done, failed) {
if (!k) return false
openpgp.decrypt({
privateKeys: k.keys,
publicKeys: sourcekeys.keys,
message: message
}).then(done).else(failed)
return true
}
/// Get Own User Name
/** Get user name as user id of first public key */
function uid() {
if (k || key()) return k.pub.keys[0].getUserIds()[0]
return null
}
/// Create New User
function createuser(user, email, pwd) {
notice("generating keys")
openpgp.generateKey({
numBits: 4096,
userIds: [{name: user, email: email}],
passphrase: pwd
}).then(function(keyPair) {
success("keys generated")
localStorage.key = keyPair.privateKeyArmored
k = keyPair.key
}).catch(function(e) {
console.log(e)
error("generating key pairs failed")
})
/// database that stores in indexed db
function DataBase() {
function user(name, key) {
}
}
function password(pwd) {
return (k || keys()) && k.keys[0].decrypt(pwd)
/// manage local copy of users
function Users() {
var users = new Map()
function add(usr) {
if (!users[usr.name])
users[usr.name].valid = true
else
users[usr.name].valid = users[usr.name].valid && users[usr.name].key == usr.key
users[usr.name].key = usr.key
users[usr.name].online = usr.online
}
}
/// Encrypt Message
function encrypt(targetkeys, message, done, failed) {
if (!k) return false
openpgp.encrypt({
publicKeys: targets.keys.concat(k.keys),
privateKeys: k,
data: message,
armor: false})
.then(done)
.else(failed)
return true
/// manage local copy of messages
function Messages() {
var messages = {};
function add() {
}
}
/// Decrypt Message
function decrypt(message) {
if (!k) return false
/// @class Communication client socket communication
/** @param view is of class SafeChat.View */
function Communication(controller) {
var socket = io.connect()
function broadcast(signal, data) {
console.log("<=snd "+signal)
socket.broadcast.emit(signal, data)
}
function emit(signal, data) {
console.log("<-snd "+signal)
socket.emit(signal, data)
}
socket
.on("login", controller.loggedin)
.on("fail", controller.fail)
.on("user", controller.user)
.on("users", controller.users)
.on("message", controller.message)
.on("messages", controller.messages)
.io
.on("connect", controller.connected)
.on("reconnect", controller.connected)
.on("disconnect", controller.disconnected)
.on("error", controller.disconnected)
return true
}
}
var password = null; ///< password, only stored temporary, until reload
var username = null; ///< username, only used during registration
var filecontent = new Array(); ///< temporary storage for attachments
var socket = io.connect();
var hostname = window.location.hostname!='localhost'?window.location.hostname:'safechat.ch';
/// @class View provides the glue to the GUI in the index.ejs file
/** View provides the following callbacks:
- status updates:
- @c notice(msg) to display information
- @c warning(msg)
- @c error(msg)
- @c fatal(msg) */
function View() {
/// Padding for numbers in dates
function pad(n) {
return n<10 ? '0'+n : n
}
var nexttimer = null
function uid(name) {
return name+' <'+name+'@'+hostname+'>';
}
/// Padding for numbers in dates
function pad(n) {
return n<10 ? '0'+n : n
}
/// escape text to show in html @see htmldec
function htmlenc(html) {
return $('<div/>').text(html).html()
}
/// decode html encoded text @see htmlenc
function htmldec(data) {
return $('<div/>').html(data).text()
}
/// Convert number of bytes to readable text
function size(num) {
if (num>0.6*1024) {
if (num>0.6*1024*1024) {
if (num>0.6*1024*1024*1024) {
if (num>0.6*1024*1024*1024*1024) {
return Math.round(num/1024/1024/1024/1024)+"TB";
/// alert user accoustically or by vibration
/** alert user, e.g. that a new message has arrived. */
function beep() {
if (navigator.vibrate) navigator.vibrate(1000)
(new Audio("sounds/beep.mp3")).play()
}
/// show fatal error
/** something completely failed, abort
@param msg */
function fatal(msg) {
if (nexttimer) clearTimeout(nexttimer)
if (msg) {
error(msg)
$('#fatal-msg').html(msg)
}
show('fatal')
}
/// show error messsage
/** shows an error message and logs to console.
@param data (optional) The error can be a string or any structure.
Strings are shown to the user, structures are logged only.
@param next (optional) next function to call */
function error(data, next) {
if (nexttimer) clearTimeout(nexttimer)
$("#status").hide()
$("#status").addClass("error")
$("#status").removeClass("notice")
$("#status").removeClass("success")
if (data) {
if (typeof data == 'string') {
$("#status").html(data)
console.log("error: "+data)
} else {
return Math.round(num/1024/1024/1024)+"GB";
$("#status").html('error')
console.log("error: "+JSON.stringify(data))
}
} else {
return Math.round(num/1024/1024)+"MB";
$("#status").html('error')
console.log("error")
}
} else {
return Math.round(num/1024)+"kB";
$("#status").show()
if (next) nexttimer = setTimeout(function() {
nexttimer = null
next()
}, 5000)
}
} else {
return num+"B";
}
}
var reboottimer = null;
/// Show error messsage
/** Fades in an error message and logs to console.
@param data (optional) The error can be a string or any structure.
Strings are shown to the user, structures are logged only.
@param stay (optional) If not given as @c true, reloads page after 5s. */
function error(data, stay) {
$("#status").hide();
$("#status").addClass("error")
$("#status").removeClass("notice")
$("#status").removeClass("success")
if (data) {
if (typeof data == 'string') {
$("#status").html(data);
console.log("error: "+data);
} else {
$("#status").html('unknown error: '+JSON.stringify(data));
console.log("error: "+JSON.stringify(data));
/// show notice messsage
/** shows an notice message and logs to console.
@param text (optional) The data is a string. */
function notice(text) {
$("#status").hide()
$("#status").addClass("notice")
$("#status").removeClass("error")
$("#status").removeClass("success")
if (text) {
$("#status").html(text)
console.log("notice: "+text)
} else {
$("#status").html('')
console.log("notice")
}
$("#status").show()
}
} else {
$("#status").html('error');
console.log("error");
}
$("#status").show();
if (!stay) {
console.log("reboot in 5s");
console.log((new Error('stacktrace')).stack);
if (!reboottimer) reboottimer = setTimeout(function() {
reboottimer = null;
start();
}, 5000);
}
}
/// Show notice messsage
/** Fades in an notice message and logs to console.
@param text (optional) The data is a string. */
function notice(text) {
$("#status").hide()
$("#status").addClass("notice")
$("#status").removeClass("error")
$("#status").removeClass("success")
if (text) {
$("#status").html(text);
console.log("notice: "+text);
} else {
$("#status").html('');
console.log("notice");
}
$("#status").show();
}
/// show success messsage
/** shows an success message and logs to console.
@param text (optional) The data is a string. */
function success(text) {
$("#status").hide()
$("#status").addClass("success")
$("#status").removeClass("error")
$("#status").removeClass("notice")
if (text) {
$("#status").html(text)
console.log("success: "+text)
} else {
$("#status").html('')
console.log("success")
}
$("#status").show()
}
/// Show notice messsage
/** Fades in an success message and logs to console.
@param text (optional) The data is a string. */
function success(text) {
$("#status").hide();
$("#status").addClass("success")
$("#status").removeClass("error")
$("#status").removeClass("notice")
if (text) {
$("#status").html(text);
console.log("success: "+text);
} else {
$("#status").html('');
console.log("success");
}
$("#status").show();
}
/// show a specific screen given the element id
/** @param id html id to be shown.
@param msg (optional) the success message text */
function show(id, msg) {
console.log("state: "+id)
if (msg) success(msg) else $("#status").hide()
$("#main").children(":not(#"+id+")").hide()
$("#main #"+id).show()
$("#main #"+id+" form input:first-child").focus()
}
/// Show status message in the main screen area
/** @param id HTML id to be shown.
@param msg The success message text */
function status(id, msg) {
console.log("state: "+id);
if (msg) success(msg); else $("#status").hide();
$("#main").children(":not(#"+id+")").hide();
$("#main #"+id).show();
$("#main #"+id+" form input:first-child").focus();
}
/// show server connected status
function connected() {
console.log("server connected")
$("#connectionstatus #bad").hide()
$("#connectionstatus #good").show()
success("server connected")
}
function emit(signal, data) {
console.log("<-snd "+signal);
socket.emit(signal, data);
}
/// show server disconnected status
function disconnected() {
console.log("server disconnected")
$("#connectionstatus #good").hide()
$("#connectionstatus #bad").show()
error("server disconnected", true)
}
/// toggle menu display
function togglemenu() {
$("#menu").toggle()
}
function connected() {
console.log("server connected");
$("#connectionstatus #bad").hide();
$("#connectionstatus #good").show();
success("server connected");
}
function checkFeature(id, query) {
if (query) $('#'+id+':before')
.css('color', 'green')
.css('content', '&#x2714;')
else $('#'+id+':before')
.css('color', 'red')
.css('content', '&#x2718;')
if (query) $('#'+id)
.css('color', 'green')
.css('text-decoration', 'line-through')
else $('#'+id)
.css('color', 'red')
.css('text-decoration', 'none')
}
function disconnected() {
console.log("server disconnected");
$("#connectionstatus #good").hide();
$("#connectionstatus #bad").show();
error("server disconnected", true);
}
function checkFeatures() {
$('ul.features').css('list-style-type', 'none')
checkFeature("localstorage", Storage)
checkFeature("indexeddb", window.indexedDB)
checkFeature("randomvalues", window.crypto.getRandomValues)
checkFeature("vibrate", navigator.vibrate)
checkFeature("filereader", window.FileReader)
}
function connectionstatus() {
if (socket.connected) connected(); else disconnected();
}
function DataTransfer() {
var reboottimer = null
var data = new DataTransfer()
/// download profile backup
function backup() {
var download = document.createElement('a')
download.href = 'data:attachment/text,'+encodeURI(JSON.stringify(localStorage))
download.target = '_blank'
var now = new Date()
download.download =
pad(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+
"-"+userid()+"@"+hostname+".bak"
var clickEvent = new MouseEvent("click", {
"view": window,
"bubbles": true,
"cancelable": false
})
download.dispatchEvent(clickEvent)
togglemenu()
}
/// Upload Profile Backup
function restore(evt) {
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f
var reader = new FileReader()
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file")
if (evt.target.readyState==0) return notice("waiting for data …")
if (evt.target.readyState==1) return notice("loading data …")
var parsed=JSON.parse(evt.target.result)
togglemenu()
localStorage.pubkey = parsed.pubkey
localStorage.privkey = parsed.privkey
success("backup is restored")
console.log("reboot after restore in 2s")
if (!reboottimer && reboot) reboottimer = setTimeout(function() {
reboottimer = null
}, 2000)
}
reader.readAsText(file)
}
}
function htmlenc(html) {
return $('<div/>').text(html).html();
}
if (!window.FileReader) {
$('restore-menu-item').hide()
error("your browser does not support file upload")
}
function htmldec(data) {
return $('<div/>').html(data).text();
}
}
/// Alert user
/** Alert user, e.g. that a new message has arrived. */
function beep(user) {
if (user) success("message from "+htmlenc(user)+" received");
navigator.vibrate =
navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate;
if (navigator.vibrate) {
// vibration API supported
navigator.vibrate(1000);
}
(new Audio("sounds/A-Tone-His_Self-1266414414.mp3")).play();
}
/// Toggle Menu Display
function togglemenu() {
$("#menu").toggle();
}
/// @class Controller defines the programm flow
function Controller(view) {
var db = new Database()
var communication = new Communication(this)
var users = new Users()
function fail(msg) {
console.log('rcv-> fail('+msg+')')
error(msg)
}
/// Download Profile Backup
function backup() {
var download = document.createElement('a');
download.href = 'data:attachment/text,'+encodeURI(JSON.stringify(localStorage));
download.target = '_blank';
var now = new Date();
download.download =
pad(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+
"-"+userid()+"@"+hostname+".bak";
var clickEvent = new MouseEvent("click", {
"view": window,
"bubbles": true,
"cancelable": false
});
download.dispatchEvent(clickEvent);
togglemenu();
}
function loggedin() {
console.log("rcv-> login")
success("login successful")
chat()
}
function user(usr) {
console.log("rcv-> user")
if (usr.exits) users.add(usr)
}
/// Upload Profile Backup
function restore(evt) {
if (!window.FileReader)
return error("your browser does not support file upload", true);
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f;
var reader = new FileReader();
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true);
if (evt.target.readyState==0) return notice("waiting for data …");
if (evt.target.readyState==1) return notice("loading data …");
var parsed=JSON.parse(evt.target.result);
togglemenu();
localStorage.pubkey = parsed.pubkey;
localStorage.privkey = parsed.privkey;
success("backup is restored");
console.log("reboot after restore in 2s");
if (!reboottimer) reboottimer = setTimeout(function() {
reboottimer = null;
start();
}, 2000);
}
reader.readAsText(file);
}
}
function users() {
console.log("rcv-> users")
}
function message(msg) {
console.log("rcv-> message")
}
function messages(msgs) {
console.log("rcv-> messages")
}
/// Configure local groups
/** … */
function groups() {
}
this.connected = view.connected
this.reconnect = view.connected
this.disconnect = view.disconnected
this.error = view.disconnected
/// Check if password is set and matches the repeated password
/** Checks if both passwords are identical and valid and gives
feedback to the user.
Called when user edits the password fields.
Sets @ref username and checks @ref password - if both are well
defined, enables the submit button.
@param pwd The password.
@param pwd2 The repeated password. */
function checkpwd(pwd, pwd2) {
$("#register").submit(function(event) {
return false;
});
if (pwd==pwd2) password=pwd;
else password=null;
if (!password||password.length<1) password=null;
$("#createuser").prop("disabled", !(username && password));
if (password) {
if (username) success("user is ready to be created");
else notice("password matches, please chose a valid user name");
} else {
if (username) notice("passwords don't match");
else if ($('#user').val()) notice("user name is already in use");
else notice("please chose a user name");
}
}
function login() {
}
/// Checks if the receiver of a message exists on server.
/** Calls checknewuser.php on server and enables the message submit
button if the receiver of the message exists on the server. */
function checkpartner(user) {
$("#chat").submit(function(event) {
return false;
});
emit("user", uid(user));
}
function user(usr) {
if (usr.exists) db.adduser
}
/// Create Local Public-/Private-Key Pair
/** Called if user has not yet his keys, just generates a new key pair. */
function createkeypair(user, pwd) {
notice("generating keys");
openpgp.generateKey({
numBits: 4096,
userIds: [{name: user, email: user+'@'+hostname}],
passphrase: pwd
}).then(function(keyPair) {
success("keys generated");
localStorage.pubkey = keyPair.publicKeyArmored;
localStorage.privkey = keyPair.privateKeyArmored;
login();
}).catch(function(e) {
console.log(e)
error("generating key pairs failed")
});
}
function initBrowser() {
window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB
window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction
window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange
navigator.vibrate = navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate
return window.indexedDB && window.crypto.getRandomValues && Storage
}
/// Get Own Public Key
/** @return public key object */
function publicKey() {
if (typeof localStorage.pubkey == 'undefined') {
if (typeof localStorage.pubKey == 'undefined') {
return null;
} else {
localStorage.pubkey = localStorage.pubKey;
localStorage.removeItem(pubKey);
function run() {
}
}
return openpgp.key.readArmored(localStorage.pubkey);
}
/// Get Own Private Key
/** @return private key object */
function privateKey() {
if (typeof localStorage.privkey == 'undefined') {
if (typeof localStorage.privKey == 'undefined') {
return null;
} else {
localStorage.privkey = localStorage.privKey;
localStorage.removeItem(privKey);
function start() {
view.reboot = run
var compatible = initBrowser()
view.checkFeatures()
if (!compatible)
view.fatal("your browser is not supported")
else
run()
}
}
return openpgp.key.readArmored(localStorage.privkey);
}
/// Get Own User Name
/** Get user name as user id of first public key */
function userid() {
if (!publicKey() ||
publicKey().keys.length < 1 ||
publicKey().keys[0].getUserIds().length < 1) return null
return publicKey().keys[0].getUserIds()[0];
}
//==============================================================================
/// Clear Message Text And Attachments
/** Does not remove the receiver's name */
function clearmessage() {
$("#message").prop(":disabled", true);
filecontent = new Array();
$('#preview').empty();
$("#msg").val("");
$("#message").prop(":disabled", false);
}
//------------------------------------------------------------------------------
return new Controller(new View())
function guessfilename(mimetype, user, date) {
if (!user) user = userid();
if (!date) date = new Date();
var ext = mimetype.replace(/.*\/(x-)?/i, "");
return pad(date.getFullYear())+pad(date.getMonth()+1)+pad(date.getDate())
+"-"+ext+"-"+user+"@"+hostname+'.'+ext;
}
var filecontent = new Array() ///< temporary storage for attachments
var reboottimer = null
/// Display Image Attachments
function attachments(files, id, from, date) {
if (files) files.forEach(function(file) {
console.log(file);
if (!file.name) file.name = guessfilename(file.type, from, date);
var a = document.createElement('a');
a.href = file.content;
a.download = file.name;
a.target = '_blank';
if (file.type.match('^image/')) {
var img = document.createElement('img');
img.title = file.name;
img.src = file.content;
a.appendChild(img);
} else if (file.type.match('^video/')) {
var video = document.createElement('video');
video.controls = true;
video.title = file.name;
video.src = file.content;
a.appendChild(video);
} else {
var img = document.createElement('img');
img.title = file.name;
img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg";
a.appendChild(img);
}
$(id).append(a);
});
}
function connectionstatus() {
if (socket.connected) connected() else disconnected()
}
/// Configure local groups
/** … */
function groups() {
}
/// Check if password is set and matches the repeated password
/** Checks if both passwords are identical and valid and gives
feedback to the user.
var recorder;
Called when user edits the password fields.
function done() {
if (recorder) {
recorder.stop();
recorder.recording(function(data) {
previewfile(data, "video/webm");
abort();
});
Sets @ref username and checks @ref password - if both are well
defined, enables the submit button.
@param pwd The password.
@param pwd2 The repeated password. */
function checkpwd(pwd, pwd2) {
$("#register").submit(function(event) {
return false
})
if (pwd==pwd2) password=pwd
else password=null
if (!password||password.length<1) password=null
$("#createuser").prop("disabled", !(username && password))
if (password) {
if (username) success("user is ready to be created")
else notice("password matches, please chose a valid user name")
} else {
if (username) notice("passwords don't match")
else if ($('#user').val()) notice("user name is already in use")
else notice("please chose a user name")
}
}
}
function abort() {
if (recorder) {
$("#videorecorder").hide();
recorder.release();
delete recorder; recorder = null;
/// Checks if the receiver of a message exists on server.
/** Calls checknewuser.php on server and enables the message submit
button if the receiver of the message exists on the server. */
function checkpartner(user) {
$("#chat").submit(function(event) {
return false
})
emit("user", uid(user))
}
}
/// Record Video from builtin camera
function recordvideo() {
try {
abort();
$("#videorecorder").show();
recorder = new MediaStreamRecorder({
video: {
width: {ideal: 180},
height: {ideal: 160}
},
audio: true
});
recorder.on("ready", function() {
$("#videorecorder video").attr("src", recorder.preview());
$("#videorecorder video").css("width", 180);
$("#videorecorder video").css("height", 160);
$("#videorecorder video").attr("width", 180);
$("#videorecorder video").attr("height", 160);
recorder.start();
});
} catch (e) {
console.log(e);
error("cannot access camera", true);
/// Create Local Public-/Private-Key Pair
/** Called if user has not yet his keys, just generates a new key pair. */
function createkeypair(user, pwd) {
notice("generating keys")
openpgp.generateKey({
numBits: 4096,
userIds: [{name: user, email: user+'@'+hostname}],
passphrase: pwd
}).then(function(keyPair) {
success("keys generated")
localStorage.pubkey = keyPair.publicKeyArmored
localStorage.privkey = keyPair.privateKeyArmored
login()
}).catch(function(e) {
console.log(e)
error("generating key pairs failed")
})
}
}
function previewfile(content, type, name) {
if (!name) name = guessfilename(type);
if (type.match('^image/')) {
var img = document.createElement("img");
img.onload = function() { // resize image to maximum 400px
var MAX = 400;
var width = img.width;
var height = img.height;
if (width > MAX) {
height *= MAX / width;
width = MAX;
}
if (height > MAX) {
width *= MAX / height;
height = MAX;
/// Get Own Public Key
/** @return public key object */
function publicKey() {
if (typeof localStorage.pubkey == 'undefined') {
if (typeof localStorage.pubKey == 'undefined') {
return null
} else {
localStorage.pubkey = localStorage.pubKey
localStorage.removeItem(pubKey)
}
var canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
img.onload = function() {
filecontent.push({name: name, type: type, content: img.src});
$("#preview").append(img);
success('image is ready to be sent');
}
return openpgp.key.readArmored(localStorage.pubkey)
}
/// Get Own Private Key
/** @return private key object */
function privateKey() {
if (typeof localStorage.privkey == 'undefined') {
if (typeof localStorage.privKey == 'undefined') {
return null
} else {
localStorage.privkey = localStorage.privKey
localStorage.removeItem(privKey)
}
img.src = canvas.toDataURL(file.type);
img.title = name+"\n"+size(img.src.length);
}
img.src=content;
} else if (type.match('^video/')) {
filecontent.push({name: name, type: type, content: content});
var video = document.createElement("video");
video.setAttribute("controls", "controls");
video.setAttribute("loop", "loop");
video.setAttribute("src", content);
video.setAttribute("title", name+"\n"+size(content.length));
$("#preview").append(video);
} else {
filecontent.push({name: name, type: type, content: content});
var img = document.createElement("img");
img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg";
img.title = name+"\n"+size(content.length);
$("#preview").append(img);
}
return openpgp.key.readArmored(localStorage.privkey)
}
}
/// Upload Attachment
/** Prepares attachment to be sent in a message. If the attachment is
an image, it resizes the image to 400px on the lager side.
By now, only images are supported.
Stores data in global variable @ref filecontent. */
function fileupload(evt) {
if (!window.FileReader)
return error("your browser does not support file upload", true);
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f;
var reader = new FileReader();
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true);
if (evt.target.readyState==0) return notice("waiting for data …");
if (evt.target.readyState==1) return notice("loading data …");
previewfile(evt.target.result, file.type, file.name);
}
reader.readAsDataURL(file);
/// Get Own User Name
/** Get user name as user id of first public key */
function userid() {
if (!publicKey() ||
publicKey().keys.length < 1 ||
publicKey().keys[0].getUserIds().length < 1) return null
return publicKey().keys[0].getUserIds()[0]
}
}
/// Sets Receiver's Name
/** Called when clicked on a receiver's name. Sets focus to the
message text field.
/// Clear Message Text And Attachments
/** Does not remove the receiver's name */
function clearmessage() {
$("#message").prop(":disabled", true)
filecontent = new Array()
$('#preview').empty()
$("#msg").val("")
$("#message").prop(":disabled", false)
}
@param name The receiver's name. */
function setreceiver(name) {
$("#recv").val(name);
checkpartner(name);
$("#msg").focus();
}
function guessfilename(mimetype, user, date) {
if (!user) user = userid()
if (!date) date = new Date()
var ext = mimetype.replace(/.*\/(x-)?/i, "")
return pad(date.getFullYear())+pad(date.getMonth()+1)+pad(date.getDate())
+"-"+ext+"-"+user+"@"+hostname+'.'+ext
}
var userMap = null;
function users(userlist) {
console.log("rcv-> users");
userMap = new Array();
$("#allusers").empty();
userlist.forEach(function(usr) {
userMap[usr.name] = usr.pubkey;
$("#allusers").append('<option value="'+htmlenc(usr.name)+'">')
$("#allusers").hide();
console.log(" user: "+usr.name);
});
localStorage.userMap = JSON.stringify(userMap);
}
/// Display Image Attachments
function attachments(files, id, from, date) {
if (files) files.forEach(function(file) {
console.log(file)
if (!file.name) file.name = guessfilename(file.type, from, date)
var a = document.createElement('a')
a.href = file.content
a.download = file.name
a.target = '_blank'
if (file.type.match('^image/')) {
var img = document.createElement('img')
img.title = file.name
img.src = file.content
a.appendChild(img)
} else if (file.type.match('^video/')) {
var video = document.createElement('video')
video.controls = true
video.title = file.name
video.src = file.content
a.appendChild(video)
} else {
var img = document.createElement('img')
img.title = file.name
img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg"
a.appendChild(img)
}
$(id).append(a)
})
}
function fail(msg) {
console.log("rcv-> fail");
error(msg);
}
var recorder
function loggedin() {
console.log("rcv-> login");
success("login successful");
chat();
}
function done() {
if (recorder) {
recorder.stop()
recorder.recording(function(data) {
previewfile(data, "video/webm")
abort()
})
}
}
function user(usr) {
if (usr.exists) console.log("rcv-> user("+usr.name+")");
else console.log("rcv-> user("+usr.name+"): name is available");
if ($("#newuser").is(":visible") && usr.name==uid($('#user').val())) {
// same username as in the create user form
$("#createuser").prop("disabled", usr.exists); // todo: check password
if (!usr.exists) {
username = usr.name;
success("user name "+usr.name+" is available");
} else {
username = null;
error("user name "+usr.name+" is in use", true);
function abort() {
if (recorder) {
$("#videorecorder").hide()
recorder.release()
delete recorder recorder = null
}
}
if ($("#chat").is(":visible") && usr.name==uid($("#recv").val())) { // same username as in receiver
$('#send').prop("disabled", !usr.exists);
$("label[for=send] img").css("opacity", usr.exists?"1.0":"0.4");
$("label[for=send] img").css("filter", usr.exists?"alpha(opacity=100)":"alpha(opacity=40)");
if (usr.exists) success("recipient exists");
else error("unknown recipient", true);
/// Record Video from builtin camera
function recordvideo() {
try {
abort()
$("#videorecorder").show()
recorder = new MediaStreamRecorder({
video: {
width: {ideal: 180},
height: {ideal: 160}
},
audio: true
})
recorder.on("ready", function() {
$("#videorecorder video").attr("src", recorder.preview())
$("#videorecorder video").css("width", 180)
$("#videorecorder video").css("height", 160)
$("#videorecorder video").attr("width", 180)
$("#videorecorder video").attr("height", 160)
recorder.start()
})
} catch (e) {
console.log(e)
error("cannot access camera", true)
}
}
if (userMap == null) {
if (localStorage.userMap) {
userMap = JSON.parse(localStorage.userMap);
function previewfile(content, type, name) {
if (!name) name = guessfilename(type)
if (type.match('^image/')) {
var img = document.createElement("img")
img.onload = function() { // resize image to maximum 400px
var MAX = 400
var width = img.width
var height = img.height
if (width > MAX) {
height *= MAX / width
width = MAX
}
if (height > MAX) {
width *= MAX / height
height = MAX
}
var canvas = document.createElement("canvas")
canvas.width = width
canvas.height = height
var ctx = canvas.getContext("2d")
ctx.drawImage(img, 0, 0, width, height)
img.onload = function() {
filecontent.push({name: name, type: type, content: img.src})
$("#preview").append(img)
success('image is ready to be sent')
}
img.src = canvas.toDataURL(file.type)
img.title = name+"\n"+size(img.src.length)
}
img.src=content
} else if (type.match('^video/')) {
filecontent.push({name: name, type: type, content: content})
var video = document.createElement("video")
video.setAttribute("controls", "controls")
video.setAttribute("loop", "loop")
video.setAttribute("src", content)
video.setAttribute("title", name+"\n"+size(content.length))
$("#preview").append(video)
} else {
userMap = new Array();
filecontent.push({name: name, type: type, content: content})
var img = document.createElement("img")
img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg"
img.title = name+"\n"+size(content.length)
$("#preview").append(img)
}
}
if (usr.exists && usr.pubkey && userMap[usr.name] != usr.pubkey) {
userMap[usr.name] = usr.pubkey;
$("#allusers").append('option value="'+htmlenc(usr.name)+'"')
localStorage.userMap = JSON.stringify(userMap);
/// Upload Attachment
/** Prepares attachment to be sent in a message. If the attachment is
an image, it resizes the image to 400px on the lager side.
By now, only images are supported.
Stores data in global variable @ref filecontent. */
function fileupload(evt) {
if (!window.FileReader)
return error("your browser does not support file upload", true)
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f
var reader = new FileReader()
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true)
if (evt.target.readyState==0) return notice("waiting for data …")
if (evt.target.readyState==1) return notice("loading data …")
previewfile(evt.target.result, file.type, file.name)
}
reader.readAsDataURL(file)
}
}
}
function queryuser(usr) {
console.log("query user: "+uid(usr));
socket.emit("user", uid(usr));
}
/// Sets Receiver's Name
/** Called when clicked on a receiver's name. Sets focus to the
message text field.
/// Get a user's public key.
/** The first time, gets it from the server, later from the cache. */
function getPublicKey(user) {
var deferredObject = $.Deferred();
if (userMap && userMap[user]) deferredObject.resolve(userMap[user]);
else deferredObject.reject("unknown user");
return deferredObject.promise();
}
/// Received a list of messages from server
function messages(msgs) {
console.log("rcv-> messages("+msgs.length+")");
if (!password || !privateKey())
return setTimeout(function() {emit("messages");}, 1000); // try again later
status("allmessages");
notice("load messages, please wait …");
msgs.forEach(function(msg) {message(msg, true);});
status("chat");
}
@param name The receiver's name. */
function setreceiver(name) {
$("#recv").val(name)
checkpartner(name)
$("#msg").focus()
}
/// Received a message from server
function message(m, internal) {
if (!internal) console.log("rcv-> message("+m.user+")");
if (!password || !privateKey()) return;
var key=openpgp.key.readArmored(m.pubkey);
if (key.err) return error("key of sender unreadable", true);
var message = openpgp.message.readArmored(m.msg);
var privkey = privateKey().keys[0];
if (privkey.decrypt(password)) // prepare own key
openpgp.decrypt({
privateKeys: privkey,
publicKeys: key.keys,
message: message
}).then(function(msg) { // decryption succeded
// prepend message to list of messages
var message = JSON.parse(msg.text);
$("#msgs") // todo: check msg.signatures[0].valid
.prepend('<div id="id'+(m.id)+'" class="msg '+
(m.user==userid()?"me":"other")+
'"><div class="header">'+
'<span class="date">'+
(new Date(m.time)).toLocaleString()+
'</span><span class="sender">'+
'<a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'+
htmlenc(m.user)+
'</a>'+(message.receiver?' → <a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'
+htmlenc(message.receiver)+'</a>':"")+
'</span></div>'+
'<div class="text">'+
htmlenc(message.text)+
'</div></div><div class="clear"/>');
// show attachments
attachments(message.files, '#id'+m.id+' .text', m.user, new Date(m.time));
// calculate and show emoticons
$('#id'+m.id).emoticonize();
if (!internal) beep(m.user);
}).catch(function(e) {
// not for me
success();
});
}
var userMap = null
function users(userlist) {
console.log("rcv-> users")
userMap = new Array()
$("#allusers").empty()
userlist.forEach(function(usr) {
userMap[usr.name] = usr.pubkey
$("#allusers").append('<option value="'+htmlenc(usr.name)+'">')
$("#allusers").hide()
console.log(" user: "+usr.name)
})
localStorage.userMap = JSON.stringify(userMap)
}
/// Send Message To Server
/** User wants to send a message. Encrypt message with own private and
the receiver's public key, then send it to the server. */
function sendmessage(recv, txt) {
notice("1/3 preparing message …");
$("#message").prop(":disabled", true);
getPublicKey(recv) // get receiver's public key
.done(function(pk) {
var key=openpgp.key.readArmored(pk);
if (!pk||key.err) {
$("#message").prop(":disabled", false);
error("receiver's key not found", true);
return;
function user(usr) {
if (usr.exists) console.log("rcv-> user("+usr.name+")")
else console.log("rcv-> user("+usr.name+"): name is available")
if ($("#newuser").is(":visible") && usr.name==uid($('#user').val())) {
// same username as in the create user form
$("#createuser").prop("disabled", usr.exists) // todo: check password
if (!usr.exists) {
username = usr.name
success("user name "+usr.name+" is available")
} else {
username = null
error("user name "+usr.name+" is in use", true)
}
var privkey = privateKey().keys[0];
privkey.decrypt(password); // get own private key ready
var message = JSON.stringify({receiver: recv, text: txt, files: filecontent});
notice("2/3 encrypting message …");
openpgp.encrypt({publicKeys: key.keys.concat(publicKey().keys),
privateKeys: privkey,
data: message,
armor: false})
.then(function(msg) { // message is encrypted
notice("3/3 sending message …");
emit("message", {user: userid(), content: msg});
clearmessage();
})
.catch(function(e) {
$("#message").prop(":disabled", false);
error("encryption of message failed", true);
});
})
.fail(function(e) {
$("#message").prop(":disabled", false);
error("user not found", true);
});
}
}
if ($("#chat").is(":visible") && usr.name==uid($("#recv").val())) { // same username as in receiver
$('#send').prop("disabled", !usr.exists)
$("label[for=send] img").css("opacity", usr.exists?"1.0":"0.4")
$("label[for=send] img").css("filter", usr.exists?"alpha(opacity=100)":"alpha(opacity=40)")
if (usr.exists) success("recipient exists")
else error("unknown recipient", true)
}
if (userMap == null) {
if (localStorage.userMap) {
userMap = JSON.parse(localStorage.userMap)
} else {
userMap = new Array()
}
}
if (usr.exists && usr.pubkey && userMap[usr.name] != usr.pubkey) {
userMap[usr.name] = usr.pubkey
$("#allusers").append('option value="'+htmlenc(usr.name)+'"')
localStorage.userMap = JSON.stringify(userMap)
}
}
/// Check And Set Password
/** Check if given password matches to decrypt the private key. If so,
store it in global temporary variable @ref password and start the
chat. The password matches, when the private key can be decrypted.
@param pwd The password to check. */
function setpw(pwd) {
if (privateKey().keys[0].decrypt(pwd)) {
success("password matches");
$("#removeKey").hide();
password = pwd;
chat();
} else {
notice("password does not match");
function queryuser(usr) {
console.log("query user: "+uid(usr))
socket.emit("user", uid(usr))
}
}
/// Create Password Entry Field
/** Asks user for password. When user starts to enter it, it is
permanentely checked in setpw(). As soon as the password matches,
setpw() continues automatically. No submit is required by the
user. */
function getpwd() {
if (password) return;
$("#removeKey").show();
status("getpwd");
}
/// Get a user's public key.
/** The first time, gets it from the server, later from the cache. */
function getPublicKey(user) {
var deferredObject = $.Deferred()
if (userMap && userMap[user]) deferredObject.resolve(userMap[user])
else deferredObject.reject("unknown user")
return deferredObject.promise()
}
function deleteUser() {
var uid = userid();
localStorage.removeItem(pubkey);
localStorage.removeItem(privkey);
error("user "+uid+" permanentely lost");
}
/// Received a list of messages from server
function messages(msgs) {
console.log("rcv-> messages("+msgs.length+")")
if (!password || !privateKey())
return setTimeout(function() {emit("messages")}, 1000) // try again later
status("allmessages")
notice("load messages, please wait …")
msgs.forEach(function(msg) {message(msg, true)})
status("chat")
}
function removeKey() {
togglemenu();
$("#removeKey").hide();
status('forgotpassword');
}
/// Received a message from server
function message(m, internal) {
if (!internal) console.log("rcv-> message("+m.user+")")
if (!password || !privateKey()) return
var key=openpgp.key.readArmored(m.pubkey)
if (key.err) return error("key of sender unreadable", true)
var message = openpgp.message.readArmored(m.msg)
var privkey = privateKey().keys[0]
if (privkey.decrypt(password)) // prepare own key
openpgp.decrypt({
privateKeys: privkey,
publicKeys: key.keys,
message: message
}).then(function(msg) { // decryption succeded
openpgp.decrypt({
privateKeys: privkey,
publicKeys: key.keys,
message: message
}).then(function(msg) { // decryption succeded
// prepend message to list of messages
var message = JSON.parse(msg.text)
$("#msgs") // todo: check msg.signatures[0].valid
.prepend('<div id="id'+(m.id)+'" class="msg '+
(m.user==userid()?"me":"other")+
'"><div class="header">'+
'<span class="date">'+
(new Date(m.time)).toLocaleString()+
'</span><span class="sender">'+
'<a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'+
htmlenc(m.user)+
'</a>'+(message.receiver?' → <a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'
+htmlenc(message.receiver)+'</a>':"")+
'</span></div>'+
'<div class="text">'+
htmlenc(message.text)+
'</div></div><div class="clear"/>')
// show attachments
attachments(message.files, '#id'+m.id+' .text', m.user, new Date(m.time))
// calculate and show emoticons
$('#id'+m.id).emoticonize()
if (!internal) beep(m.user)
}).catch(function(e) {
// not for me
success()
})
}
/// Main Chat Window
/** Gets chat widgets from server and displays them. Starts timer for
get() which polls for new messages. */
var firsttime = true;
function chat() {
if (!password) return getpwd();
status("chat");
if (firsttime && $('#msgs').is(':empty')) {
firsttime = false;
notice("getting previous messages, please wait …");
emit("messages");
}
}
/// Send Message To Server
/** User wants to send a message. Encrypt message with own private and
the receiver's public key, then send it to the server. */
function sendmessage(recv, txt) {
notice("1/3 preparing message …")
$("#message").prop(":disabled", true)
getPublicKey(recv) // get receiver's public key
.done(function(pk) {
var key=openpgp.key.readArmored(pk)
if (!pk||key.err) {
$("#message").prop(":disabled", false)
error("receiver's key not found", true)
return
}
var privkey = privateKey().keys[0]
privkey.decrypt(password) // get own private key ready
var message = JSON.stringify({receiver: recv, text: txt, files: filecontent})
notice("2/3 encrypting message …")
openpgp.encrypt({publicKeys: key.keys.concat(publicKey().keys),
privateKeys: privkey,
data: message,
armor: false})
.then(function(msg) { // message is encrypted
notice("3/3 sending message …")
emit("message", {user: userid(), content: msg})
clearmessage()
})
.catch(function(e) {
$("#message").prop(":disabled", false)
error("encryption of message failed", true)
})
})
.fail(function(e) {
$("#message").prop(":disabled", false)
error("user not found", true)
})
}
/// Login User
/** This is not really a login, it is just some kind of validation.
The server does not care if a user is online or not, it is only
interesting to the client to make sure, everything is fine. User
is logged in the following way: User name and public key are sent
to the server. If the user name exists on the server and the
public key is the same, the user is considered logged in, his
credentials seem to be valid. If user does not yet exits on
server, it is created now. If user exists, but public key is
different, then this is a complete failure, something went
terribly wrong. */
function login() {
$("#username").html(userid()+"@"+hostname);
emit("login", {name: userid(),
pubkey: localStorage.pubkey});
success("login sent to server");
}
/// Check And Set Password
/** Check if given password matches to decrypt the private key. If so,
store it in global temporary variable @ref password and start the
chat. The password matches, when the private key can be decrypted.
@param pwd The password to check. */
function setpw(pwd) {
if (privateKey().keys[0].decrypt(pwd)) {
success("password matches")
$("#removeKey").hide()
password = pwd
chat()
} else {
notice("password does not match")
}
}
/// Get And Display Form To Create New User
/** Shows user creation form. On submit, a private key is generated in
createkeypair(), then login() creates the user. */
function newuser() {
status("newuser");
}
/// Create Password Entry Field
/** Asks user for password. When user starts to enter it, it is
permanentely checked in setpw(). As soon as the password matches,
setpw() continues automatically. No submit is required by the
user. */
function getpwd() {
if (password) return
$("#removeKey").show()
status("getpwd")
}
/// Check if local storage is available
function checkLocalStorage() {
var test = 'test';
try {
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch(e) {
status("nolocalstorage");
error("local storage not available");
}
return false;
}
function deleteUser() {
var uid = userid()
localStorage.removeItem(pubkey)
localStorage.removeItem(privkey)
error("user "+uid+" permanentely lost")
}
/// Initial Function: Startup
/** Decide whether to login or to create a new user */
function start() {
$("#menu").hide();
//status("startup");
if (checkLocalStorage())
try {
if (!userid()) {
newuser();
} else {
login();
}
} catch (m) {
console.log(m.stack);
error(m);
}
}
function removeKey() {
togglemenu()
$("#removeKey").hide()
status('forgotpassword')
}
function init() {
/// On Load, Call @ref start
$(window.onbeforeunload = function() {
return "Are you sure you want to navigate away?";
});
/// Allow Running in Background on Android
document.addEventListener('deviceready', function () {
if (cordova && cordova.plugins.backgroundMode) {
cordova.plugins.backgroundMode.enable();
}
}, false);
socket.io.on("connect", connected);
socket.io.on("reconnect", connected);
socket.io.on("disconnect", disconnected);
socket.io.on("error", disconnected);
socket.on("login", loggedin);
socket.on("fail", fail);
socket.on("user", user);
socket.on("users", users);
socket.on("message", message);
socket.on("messages", messages);
connectionstatus();
if (openpgp.initWorker("openpgp.worker.min.js"))
console.log("asynchronous openpgp enabled");
else
console.log("asynchronous openpgp failed");
emit('users');
start();
}
/// Main Chat Window
/** Gets chat widgets from server and displays them. Starts timer for
get() which polls for new messages. */
var firsttime = true
function chat() {
if (!password) return getpwd()
status("chat")
if (firsttime && $('#msgs').is(':empty')) {
firsttime = false
notice("getting previous messages, please wait …")
emit("messages")
}
}
/// Login User
/** This is not really a login, it is just some kind of validation.
The server does not care if a user is online or not, it is only
interesting to the client to make sure, everything is fine. User
is logged in the following way: User name and public key are sent
to the server. If the user name exists on the server and the
public key is the same, the user is considered logged in, his
credentials seem to be valid. If user does not yet exits on
server, it is created now. If user exists, but public key is
different, then this is a complete failure, something went
terribly wrong. */
function login() {
$("#username").html(userid()+"@"+hostname)
emit("login", {name: userid(),
pubkey: localStorage.pubkey})
success("login sent to server")
}
/// Get And Display Form To Create New User
/** Shows user creation form. On submit, a private key is generated in
createkeypair(), then login() creates the user. */
function newuser() {
status("newuser")
}
/// Check if local storage is available
function checkLocalStorage() {
var test = 'test'
try {
localStorage.setItem(test, test)
localStorage.removeItem(test)
return true
} catch(e) {
status("nolocalstorage")
error("local storage not available")
}
return false
}
/// Initial Function: Startup
/** Decide whether to login or to create a new user */
function start() {
$("#menu").hide()
//status("startup")
if (checkLocalStorage())
try {
if (!userid()) {
newuser()
} else {
login()
}
} catch (m) {
console.log(m.stack)
error(m)
}
}
function init() {
/// On Load, Call @ref start
$(window.onbeforeunload = function() {
return "Are you sure you want to navigate away?"
})
/// Allow Running in Background on Android
document.addEventListener('deviceready', function () {
if (cordova && cordova.plugins.backgroundMode) {
cordova.plugins.backgroundMode.enable()
}
}, false)
connectionstatus()
if (openpgp.initWorker("openpgp.worker.min.js"))
console.log("asynchronous openpgp enabled")
else
console.log("asynchronous openpgp failed")
emit('users')
start()
}
/// Start Main Loop
$(init);
/// Start Main Loop
$(init)

@ -30,7 +30,7 @@
<ul id="menu" style="display: none">
<li onclick="backup()">Download Backup</li>
<li class="toolbutton"><label for="restore">Restore Backup</label><input autocomplete="off" type="file" accept="*.bak" id="restore" /></li>
<li id="restore-menu-item" class="toolbutton"><label for="restore">Restore Backup</label><input autocomplete="off" type="file" accept="*.bak" id="restore" /></li>
<li id="groups" onclick="groups()">Edit Groups</li>
<li id="removeKey" style="display: none" onclick="removeKey()">Password Forgotten</li>
<li id="android-download" href="safechat.apk"><a href="safechat.apk">Download Android-App</a></li>
@ -182,6 +182,25 @@
</div>
</div>
<!-- Fatal: Abort -->
<div id="fatal">
<h2 id="fatal-msg">Failure</h2>
<p>The SafeChat has been aborted due to a fatal error.</p>
<p>There is a problem in your browser. Please try to reload. If the problem persists, please update your web browser or try SafeChat in another browser.</p>
<p>The following java script features are required:</p>
<ul class="features">
<li id="localstorage">Local Storage</li>
<li id="indexeddb">Indexed DB</li>
<li id="randomvalues">Cryptography: Random Values</li>
</ul>
<p>The following java script features are optional:</p>
<ul class="features">
<li id="vibrate">Vibration (vibrates when new message arrives)</li>
<li id="filereader">File Reader (required to restore backup)</li>
</ul>
</div>
<!-- Error: Missing JavaScript -->
<noscript>
<h2>JavasScript Required!</h2>
@ -193,12 +212,6 @@
<p><a href="<%= package.documentation %>" target="_blank">more information</a></p>
</noscript>
<!-- Error: Missing LocalStorage -->
<div id="nolocalstorage" style="display: none">
<p>No access to local storage. Please allow access to local
storage, i.e. do not block cookies.<p>
</div>
<!-- Notice: Setup Messages -->
<div id="allmessages" style="display: none">
<p>Setting up all previous messages, please wait …</p>

Loading…
Cancel
Save