Browse Source

First version

HelloZeroNet 8 years ago
parent
commit
955af09d64

+ 51 - 0
content.json

@@ -0,0 +1,51 @@
+{
+ "address": "1MaiL5gfBM1cyb4a8e3iiL8L5gXmoAJu27", 
+ "background-color": "#FFF", 
+ "description": "End-to-end encrypted messaging", 
+ "domain": "Mail.ZeroNetwork.bit", 
+ "files": {
+  "css/all.css": {
+   "sha512": "f80b741844ddf5626a124b009af71e99d6cdef86f7fce86cb9428a2aebbd3d1c", 
+   "size": 158546
+  }, 
+  "dbschema.json": {
+   "sha512": "0883202ab2d5d551ac458f799b74596de736a312c1a07c715a9b9001a210f29f", 
+   "size": 915
+  }, 
+  "echobot/echobot.py": {
+   "sha512": "25a60e84c4f2feee608d4dd03c963212af235d5e31c874ddc38baa4ed64a6da8", 
+   "size": 1716
+  }, 
+  "img/loading.gif": {
+   "sha512": "8a42b98962faea74618113166886be488c09dad10ca47fe97005edc5fb40cc00", 
+   "size": 723
+  }, 
+  "index.html": {
+   "sha512": "b5d6e8d48f75d00ee06edd61f6094766567a2a89556a7612852d5779807e4a7c", 
+   "size": 1201
+  }, 
+  "js/all.js": {
+   "sha512": "ba26319fea9304a086456fabf8e1bac8e546cd9948968e3430b332a9a4e47268", 
+   "size": 163748
+  }
+ }, 
+ "ignore": "((js|css)/(?!all.(js|css))|data/.*db|data/users/.*/.*)", 
+ "includes": {
+  "data/users/content.json": {
+   "signers": [], 
+   "signers_required": 1
+  }
+ }, 
+ "modified": 1449780867.411, 
+ "sign": [
+  18749909576730668722916531691456327916600591425064766636589800946810869990689, 
+  106666357266686228660195337246676380489652196996330546235846480829235396783387
+ ], 
+ "signers_sign": "HHU/C9Uy/vejqoiBvXWfnjvgKvaRb1kqMC67nqguz4FoOEeT6o7yF2pQq7VUaUqR5v5XIM1Ys0L4nDTGQEUn82o=", 
+ "signs": {
+  "1MaiL5gfBM1cyb4a8e3iiL8L5gXmoAJu27": "G1sdkHaRcLhGVpjcKUFIU6ZpaiXxEkWmPD+mpp5YKXUAgq93PoPEFWYiFtGQMjhr4iElkhrmeMltunF9XOY3Sl4="
+ }, 
+ "signs_required": 1, 
+ "title": "ZeroMail", 
+ "zeronet_version": "0.3.4"
+}

+ 8 - 0
css/Autocomplete.css

@@ -0,0 +1,8 @@
+.Autocomplete .values {
+	position: absolute; background-color: #FFFEF1; box-shadow: 0px 5px 21px -4px rgba(0,0,0,0.1); padding: 5px 0px;
+	/*border: 1px solid #eee; margin-left: 5px; margin-top: 5px;*/ line-height: 30px; /*border-radius: 5px; */
+}
+.Autocomplete .value { display: block; color: black; font-weight: normal; padding: 3px 20px; color: #333; font-size: 15px; transition: all 0.3s }
+.Autocomplete .values:hover .value.selected { background-color: inherit; color: inherit }
+.Autocomplete .values .value.selected { background-color: #FFF8CA; color: #111; transition: none }
+.Autocomplete .values .value:hover { background-color: #FFF8CA !important; color: #111 !important; transition: none}

+ 35 - 0
css/Button.css

@@ -0,0 +1,35 @@
+.button {
+	padding: 5px 10px; margin-left: 10px; background-color: #DDE0E0; border-bottom: 2px solid #999998; background-position: left center;
+	border-radius: 2px; text-decoration: none; transition: all 0.5s ease-out; color: #333; display: inline-block
+}
+.button:hover { background-color: #FFF400; border-color: white; border-bottom: 2px solid #4D4D4C; transition: none; color: inherit }
+.button:active { position: relative; top: 1px }
+.button.loading {
+	color: rgba(0,0,0,0) !important; background: #999 url(../img/loading.gif) no-repeat center center;
+	transition: all 0.5s ease-out; pointer-events: none; border-bottom: 2px solid #666
+}
+
+.button-create {
+	display: block; border: 1px solid #3F4269; padding: 10px 20px; color: white; transition: 0.3s;
+	margin: 17px 20px; border-radius: 3px; text-align: center; background-color: #2A2D59;
+}
+.button-create:hover { color: #FFF; background-color: rgba(255, 255, 255, 0.05); transition: 0s; border-color: #03A9F4 }
+.button-create:active { background-color: rgba(255, 255, 255, 0.1); transition: 0s; transform: translateY(1px) }
+
+
+.button-submit {
+	padding: 15px 30px; margin: 10px; margin-bottom: 15px; background-color: #FFF85F; border-bottom: 1px solid #CDBD1E; background-position: left center;
+	border-radius: 2px; text-decoration: none; transition: all 0.5s ease-out; color: #333; font-family: monospace; font-size: 13px; display: inline-block;
+}
+.button-submit:hover { background-color: #FFF400; border-bottom: 1px solid #4D4D4C; transition: none; color: inherit }
+.button-submit:active { position: relative; top: 1px }
+.button-submit.disabled { color: #999; border-color: #EEE; background-color: #EEE; pointer-events: none }
+
+/*
+.button-certselect {
+	left: 50%; transform: translateX(-60px); z-index: 99; position: relative; border-bottom-color: #3543F9; font-family: monospace;
+	font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin-top: 13px; background-color: #007AFF; color: white;
+}
+.button-certselect:hover { background-color: #3396FF; color: white; border-bottom-color: #5D68FF; }
+.button-certselect:active { transform: translateX(-60px) translateY(1px); top: auto; }
+*/

+ 116 - 0
css/ZeroMail.css

@@ -0,0 +1,116 @@
+body, html { height: 100%; min-height: 100%; }
+body { background-color: #F1F4F8; font-family: 'Roboto', sans-serif; backface-visibility: hidden; margin: 0px; padding: 0px }
+a { color: #5CABDF; font-weight: normal; text-decoration: none }
+h1, h2, h3 { font-weight: normal }
+h5, h6 { font-weight: normal; color: #999 }
+
+
+.username { color: inherit }
+.username .bullet { font-size: 170%; line-height: 15px; vertical-align: middle; margin-right: 5px }
+
+/* Left */
+
+.Leftbar { width: 230px; float: left; height: 100%; position: fixed; background-color: #2A2D59; }
+.Leftbar .logo { background-color: #FF5E3B; padding: 20px; color: white; text-transform: uppercase; font-size: 20px; font-family: monospace; display: block; }
+.Leftbar .folders a {
+	color: rgba(255, 255, 255, 0.5); display: block; padding: 20px 20px; font-weight: bold; text-transform: uppercase;
+	font-size: 14px; border-bottom: 1px solid rgba(255,255,255, 0.1); transition: 0.3s
+}
+.Leftbar .folders a.active { color: rgba(255, 255, 255, 1); background-color: rgba(255, 255, 255, 0.05) }
+.Leftbar .folders a:hover { color: rgba(255, 255, 255, 1); background-color: rgba(255, 255, 255, 0.05); transition: 0s }
+
+.Leftbar h2 { opacity: 0.5; color: white; padding: 5px 20px; font-size: 11px; text-transform: uppercase; margin-top: 50px }
+.Leftbar .contacts a { padding: 5px 20px; display: block; color: rgba(255, 255, 255, 0.7); font-size: 14px; font-weight: normal; transition: 0.3s }
+.Leftbar .contacts a:hover { color: rgba(255, 255, 255, 1); transition: none }
+.Leftbar .logout { bottom: 0px; position: absolute; padding: 10px; opacity: 0.5; transition: all 0.3s }
+.Leftbar .logout:hover { opacity: 0.8; transition: none }
+
+/* Center */
+
+.center {
+	width: 426px; padding-top: 37px; padding-bottom: 10px; border-right: 1px solid #F1F4F8;
+	overflow-y: auto; overflow-x: hidden; left: 230px; height: 100%; position: fixed; background-color: white; box-sizing: border-box;
+}
+.MessageList.empty { text-align: center; color: #AAA; margin-top: 120px; }
+.MessageList .Message {
+	padding: 10px; border-top: 1px solid #F1F4F8; border-bottom: 1px solid rgba(0,0,0,0); background-color: #FFF;
+	display: block; padding-left: 26px; clear: both; transition: all 0.3s
+}
+.MessageList .Message:hover { background-color: #F7F9F9; transition: none }
+.MessageList .Message.active { background-color: #FFFAE5; border-bottom: 1px solid #FAF0C8; border-top: 1px solid #FAF0C8; box-shadow: 0px 1px 4px rgba(0,0,0,0.1); transition: all 0.5s }
+.MessageList .Message:before { content: "\2022"; float: left; padding: 19px 0px; font-size: 25px; color: #74BBFE; font-family: Arial; margin-left: -17px; opacity: 0; transition: all 0.3s; transform: scale(1.5) }
+.MessageList .Message.unread:before { opacity: 1; transform: scale(1) }
+.MessageList .Message:first-child { border-top: none }
+.MessageList .Message .sent { color: #AAA; float: right; font-size: 80%; font-weight: lighter; line-height: 130% }
+.MessageList .Message .from, .MessageList .Message .to { color: #3D3D3D; font-size: 80%; font-weight: bold; margin-top: 4px }
+.MessageList .Message .subject { color: black; font-weight: bold; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
+.MessageList .Message .preview { color: #6F6E6E; margin-top: 5px; margin-bottom: 5px; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* Right */
+
+.right { width: 682px; margin-left: 656px; box-sizing: border-box; background-color: white; min-height: 100% }
+
+.StartScreen .term {
+	font-size: 13px; white-space: pre; line-height: 25px; font-family: monospace; color: #333; text-align: center; margin-bottom: 10px;
+	/*transform: rotateY(20deg); opacity: 0; transition: all 1s; transform-origin: top left*/
+}
+.StartScreen .term * { opacity: 0; transition: all 1s ; transform: scale(0.9); box-shadow: -920px 0px 0px #333 inset; /*max-width: 0px; overflow: hidden; display: inline-block*/ }
+.StartScreen .term.visible * { opacity: 1; transform: scale(1); box-shadow: 0px 0px 0px #333 inset; max-width: 900px }
+.StartScreen.visible .term { transform: rotateX(0deg); opacity: 1 }
+.StartScreen .banner { text-align: center; line-height: 16px; }
+.StartScreen .button-certselect { left: 50%; margin-left: -80px; position: relative; }
+
+.MessageShow { padding: 40px 50px }
+.MessageShow .subject { font-size: 210%; font-weight: lighter; }
+.MessageShow .tools { float: right; margin-top: 15px; margin-right: -6px; }
+.MessageShow .tools .icon { opacity: 0.3; transition: all 0.3s; padding: 5px; margin-left: 10px; border-radius: 30px }
+.MessageShow .tools .icon:hover { opacity: 1; transition: background-color 1s }
+.MessageShow .tools .icon:active { transition: none; background-color: #EEE }
+.MessageShow .from, .MessageShow .to { font-size: 80%; color: #333; margin-top: 5px; border-bottom: 1px solid #F1F4F8; padding-bottom: 10px }
+.MessageShow .from .username, .MessageShow .to .username { font-weight: bold }
+.MessageShow .sent { color: #999; float: right; font-size: 80%; font-weight: lighter; margin-top: 5px; }
+.MessageShow .body { line-height: 1.5em; margin-top: 20px; color: #111; font-family: monospace; font-size: 16px; word-wrap: break-word; }
+.MessageShow .body blockquote { border-left: 5px solid #eee; padding: 5px; padding-left: 15px; margin: 10px 0px; background-color: #FBFBFB; }
+.MessageShow .body blockquote p { margin: 0px; font-size: 90% }
+.MessageShow .Message { transition: all 1s }
+.MessageShow .Message.deleted { perspective: 900px }
+.MessageShow .Message.deleted * { transform: scale(0.5) rotateX(-90deg); opacity: 0; transition: all 0.6s cubic-bezier(0.6, -0.28, 0.735, 0.045); }
+.MessageCreate-opened .MessageShow .Message { margin-bottom: 500px }
+
+
+.MessageCreate { box-shadow: 0px -11px 50px -6px #D4D4D4; position: fixed; bottom: 0px; width: 682px; transition: 0.6s all cubic-bezier(0.77, 0, 0.175, 1); background-color: white }
+.MessageCreate.minimized { transform: translateY(100%); bottom: 45px }
+.MessageCreate.empty.minimized { box-shadow: 0px 0px 0px 0px #D4D4D4; }
+.MessageCreate.empty.minimized .titlebar { background-color: white; color: #BCC0C5; border-top: 1px solid #F1F4F8 }
+.MessageCreate.sent .titlebar { background-color: #2ECC71; color: white; border-top: 1px solid #2ECC71 }
+.MessageCreate.minimized .minimize { opacity: 0; transition: opacity 1s !important }
+.MessageCreate .titlebar { background-color: #FF5E3B; padding: 15px 20px; color: white; font-family: monospace; display: block; transition: 0.3s all }
+.MessageCreate .titlebar .buttons { float: right; margin-top: -18px; margin-right: -15px; height: 45px; }
+.MessageCreate .titlebar .buttons a { color: white; font-weight: normal; font-size: 19px; padding: 15px; display: inline-block; transition: 0.3s all }
+.MessageCreate .titlebar .buttons a:hover { background-color: rgba(255,255,255,0.1); transition: none }
+.MessageCreate .titlebar .buttons .minimize { font-size: 14px; vertical-align: 3px }
+.MessageCreate input, .MessageCreate textarea {
+	width: 100%; padding: 17px; box-sizing: border-box; font-family: Roboto; transition: all 0.3s;
+	border: 1px solid rgba(0,0,0,0); border-bottom: 1px solid #eee; outline: none;
+}
+.MessageCreate input:focus, .MessageCreate textarea:focus { border-color: #D6D6D6; transition: none }
+.MessageCreate textarea { color: #111; font-family: monospace; font-size: 16px; min-height: 265px }
+.MessageCreate .label-to { position: absolute; margin-left: 17px; margin-top: 18px; opacity: 0.5 }
+.MessageCreate .to { font-size: 16px; padding-left: 50px }
+.MessageCreate .Autocomplete .values { margin-left: 31px }
+.MessageCreate .subject { font-size: 20px; font-weight: lighter }
+
+
+
+.animate { transition: all 0.3s ease-out !important; }
+.animate-back { transition: all 1s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; }
+.animate-inout { transition: all 0.6s cubic-bezier(0.77, 0, 0.175, 1) !important; }
+
+.cursor { color: #999; animation: pulse 1.5s infinite ease-in-out; }
+@keyframes pulse {
+  0%   { opacity: 0 }
+  5%   { opacity: 1 }
+  30%  { opacity: 1 }
+  70%  { opacity: 0 }
+  100% { opacity: 0 }
+}

File diff suppressed because it is too large
+ 199 - 0
css/all.css


File diff suppressed because it is too large
+ 5 - 0
css/fonts.css


+ 124 - 0
css/github.css

@@ -0,0 +1,124 @@
+/*
+
+github.com style (c) Vasily Polovnyov <vast@whiteants.net>
+
+*/
+
+.hljs {
+  display: block;
+  overflow-x: auto;
+  padding: 0.5em;
+  color: #333;
+  background: #f8f8f8;
+  -webkit-text-size-adjust: none;
+}
+
+.hljs-comment,
+.diff .hljs-header,
+.hljs-javadoc {
+  color: #998;
+  font-style: italic;
+}
+
+.hljs-keyword,
+.css .rule .hljs-keyword,
+.hljs-winutils,
+.nginx .hljs-title,
+.hljs-subst,
+.hljs-request,
+.hljs-status {
+  color: #333;
+  font-weight: bold;
+}
+
+.hljs-number,
+.hljs-hexcolor,
+.ruby .hljs-constant {
+  color: #008080;
+}
+
+.hljs-string,
+.hljs-tag .hljs-value,
+.hljs-phpdoc,
+.hljs-dartdoc,
+.tex .hljs-formula {
+  color: #d14;
+}
+
+.hljs-title,
+.hljs-id,
+.scss .hljs-preprocessor {
+  color: #900;
+  font-weight: bold;
+}
+
+.hljs-list .hljs-keyword,
+.hljs-subst {
+  font-weight: normal;
+}
+
+.hljs-class .hljs-title,
+.hljs-type,
+.vhdl .hljs-literal,
+.tex .hljs-command {
+  color: #458;
+  font-weight: bold;
+}
+
+.hljs-tag,
+.hljs-tag .hljs-title,
+.hljs-rules .hljs-property,
+.django .hljs-tag .hljs-keyword {
+  color: #000080;
+  font-weight: normal;
+}
+
+.hljs-attribute,
+.hljs-variable,
+.lisp .hljs-body {
+  color: #008080;
+}
+
+.hljs-regexp {
+  color: #009926;
+}
+
+.hljs-symbol,
+.ruby .hljs-symbol .hljs-string,
+.lisp .hljs-keyword,
+.clojure .hljs-keyword,
+.scheme .hljs-keyword,
+.tex .hljs-special,
+.hljs-prompt {
+  color: #990073;
+}
+
+.hljs-built_in {
+  color: #0086b3;
+}
+
+.hljs-preprocessor,
+.hljs-pragma,
+.hljs-pi,
+.hljs-doctype,
+.hljs-shebang,
+.hljs-cdata {
+  color: #999;
+  font-weight: bold;
+}
+
+.hljs-deletion {
+  background: #fdd;
+}
+
+.hljs-addition {
+  background: #dfd;
+}
+
+.diff .hljs-change {
+  background: #0086b3;
+}
+
+.hljs-chunk {
+  color: #aaa;
+}

+ 59 - 0
css/icons.css

@@ -0,0 +1,59 @@
+.icon { display: inline-block; vertical-align: text-bottom; background-repeat: no-repeat; background-position: 50% 50% }
+.icon-profile { font-size: 6px; top: 0em; border-radius: 0.7em 0.7em 0 0; background: #FFFFFF; width: 1.5em; height: 0.7em; position: relative; display: inline-block; margin-right: 4px }
+.icon-profile:before { position: absolute; content: ""; top: -1em; left: 0.38em; width: 0.8em; height: 0.85em; border-radius: 50%; background: #FFFFFF; }
+
+.icon-comment { width: 16px; height: 10px; border-radius: 2px; background: #464545; margin-top: 0px; display: inline-block; position: relative; top: 0px; }
+.icon-comment:after { left: 9px; border: 2px solid transparent; border-top-color: #464545; border-left-color: #333; background: transparent; content: ""; display: block; margin-top: 10px; width: 0px; margin-left: 7px; }
+
+.icon-edit {
+	width: 16px; height: 16px; background-repeat: no-repeat; background-position: 20px center;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAOVBMVEUAAAC9w8e9w8e9w8e9w8e/xMi9w8e9w8e+w8e9w8e9w8e9w8e9w8e9w8e9w8e+w8e/xMi9w8e9w8fvY4+KAAAAEnRSTlMASPv3WQbwOTCkt4/psX4YDMWr+RRCAAAAUUlEQVQY06XLORKAMAxDUTs7kA3d/7AYGju0UfffjIgoHkxm0vB5bZyxKHx9eX0FJw0Y4bcXKQ4/CTtS5yqp5GFFOjGpVGl00k1pNDIb3Nv9AHC7BOZC4ZjvAAAAAElFTkSuQmCC+d0ckOwyAMRVGHUOO0gUyd+P8f7WApz4Iki9wFmyOEATrXLZcFp5LrGogPOxKp6zfFf9fZ1/I/cY7YZSS3U6S3XFZJmGBwL+FuJX/F1K0wUUlZyZGlXgXESthTEs4B8fh7xoVUDPGYJnsfkCRarKAgz8cAKbpD6pqDPz3XB8K6HdUEeN9NAAAAAElFTkSuQmCC);
+}
+.icon-reply {
+	width: 16px; height: 16px;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAIVBMVEUAAABmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmYs5FxxAAAAC3RSTlMAgBFwYExAMHgoCDJmUTYAAAA3SURBVAjXY8APGGEMQZgAjCEoKBwEEQCCAoiIh6AQVM1kMaguJhGYOSJQjexiUMbiAChDCclCAOHqBBdHpwQTAAAAAElFTkSuQmCC);
+}
+.icon-trash {
+	width: 16px; height: 16px;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAYNJREFUeNqc0ssvQ0EUx/Hb3lKvxPsVaSgiQkhImmLhL7AVK4l4LEX8FdbWaKzYibU9Cd0QiQUbEUVLW+LZovWd5Hflhq56k097M3fmzDlnxmNZ1jiarOKehI+fIdQWGeDR5ucUPUiiDGfYwjEG8Yo1HKAFJThHpRk3AT7Rhm7sIYB3DOAaL2hElTI1G4YQQ9SnVO6RMQOKvowrRPR9EfVY0byQ1li2JtgavMQoblGBLwS1+40yNQFGtFncySCpDzM4wa5qXNL4qoJNYw453JmFTgBT55Pe61RzUJNMdr2m4/Cr9hqk3QHyzgDPPhbwgA2UYl4ZrSMMj5pveV1nGtMxBZSN+Vatzud0nK36HrNczXO/h3XOm8pgEn3YwSEm0Iwj08C/GSS1k7kDWbwpmFfvH/jW3LizyOcK8IwUOjCrxdsox5QW+1VKulCAnDqdUo1d+reVRVTpt+to//XAPA0YVoc70Y8x3cK8rveFbmvBAFml7NUdyKi0uEpK6KL9lvAjwADJLmZZEK9ifwAAAABJRU5ErkJggg==);
+}
+.icon-logout {
+	width: 15px; height: 16px;
+	background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAASRJREFUeNqM079LQlEUwPH3TMRfINJgpoPRpNBmU7S6iNvLvyCI/ptwFiFwaUrEpUXpD3BraXJIcNGloDSs1/fCeXA7PPEd+Ih6Pefce5/H9X3fIY5xhRI+0cXc2RckVzHz/8e9KbpPjPwOKlLrB1+Yqh7pXZ2X0m2IExRVhwYWsp7WnWNS5xUzLFT9SxyhhUek7M4r6Xy342w5PFv38YRU0FlHXDoFErjBi6w3MEAmbiVtkcUENVVwax0vKNDXyXnUnWhxYScn8YZ2SIG1/Imq8nmD6ygXZrTxIb9bo2W+N51da9th0cSDvP+Gh5Ejl/ArC6co41AlF8ISg+c8tp7hBu+4tbZ8AA/n+jjm5QxzNRj9KIPhykiWZEtFGclelJH8E2AAz/FT3lY/ZaYAAAAASUVORK5CYII=);
+}
+.icon-heart { position: absolute; width: 17px; height: 13px; margin-top: 5px }
+.icon-heart:before, .icon-heart:after {
+	position: absolute; content: ""; left: 8px; top: 0; width: 8px; height: 13px;
+	background: #FA6C8D; /*border-radius: 25px 25px 0 0;*/ transform: rotate(-45deg); transform-origin: 0 100%
+}
+.icon-heart:after { left: 0; transform: rotate(45deg); transform-origin :100% 100% }
+.icon-up { font-weight: normal !important; font-size: 15px; font-family: Tahoma; vertical-align: -4px; padding-right: 5px; display: inline; height: 1px; }
+
+.icon-mail {
+  margin: 110px auto;
+  background-color: #FFF;
+  position: relative;
+  height: 70px;
+  width: 100px;
+  overflow: hidden;
+}
+.icon-mail:before, .icon-mail:after {
+  content: '';
+  position: absolute;
+  left: 50%;
+  height: 60px;
+  width: 60px;
+  transform: rotate(45deg) skew(-5deg, -5deg);
+  margin-left: -50px;
+  height: 100px;
+  width: 100px;
+  background-color: #FFF;
+}
+.icon-mail:before {
+  bottom: -100%;
+  box-shadow: -5px -5px 0 0 #EBABAB;
+}
+.icon-mail:after {
+  top: -95%;
+  box-shadow: 5px 5px 0 0 #EBABAB;
+}

+ 17 - 0
css/scrollbar.css

@@ -0,0 +1,17 @@
+/* - SCROLL - */
+*::-webkit-scrollbar {
+	width: 8px;
+	height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+	-webkit-box-shadow: 0px 0px 3px #dfdfdf inset; -moz-box-shadow: 0px 0px 3px #dfdfdf inset; -o-box-shadow: 0px 0px 3px #dfdfdf inset; -ms-box-shadow: 0px 0px 3px #dfdfdf inset; box-shadow: 0px 0px 3px #dfdfdf inset ;
+	-webkit-border-radius: 10px; -moz-border-radius: 10px; -o-border-radius: 10px; -ms-border-radius: 10px; border-radius: 10px ;
+}
+*::-webkit-scrollbar-thumb {
+	background-color: #DDD;
+	-webkit-border-radius: 10px; -moz-border-radius: 10px; -o-border-radius: 10px; -ms-border-radius: 10px; border-radius: 10px ;
+}
+*::-webkit-scrollbar-thumb:hover {
+	background-color: #CCC;
+}

+ 27 - 0
data/users/content.json

@@ -0,0 +1,27 @@
+{
+ "files": {}, 
+ "ignore": ".*", 
+ "modified": 1449698701.563, 
+ "signs": {
+  "1MaiL5gfBM1cyb4a8e3iiL8L5gXmoAJu27": "G0rUIk3topYwVD2/6BgUHdCbjG7nZ0ytC0VXIgwqeX/BZKd05CTvns6HWmTKnEEPvwA3wvK3Pn4MdX1VVByoGTY="
+ }, 
+ "user_contents": {
+  "cert_signers": {
+   "zeroid.bit": [ "1iD5ZQJMNXu43w1qLB8sfdHVKppVMduGz" ]
+  }, 
+  "content_inner_path": "data/users/content.json", 
+  "optional": null, 
+  "permission_rules": {
+   ".*": {
+    "files_allowed": "data.json", 
+    "max_size": 30000
+   }, 
+   "bitid/.*@zeroid.bit": { "max_size": 40000 }
+  }, 
+  "permissions": {
+   "bad@zeroid.bit": false, 
+   "echobot@zeroid.bit": { "max_size": 200000 }, 
+   "nofish@zeroid.bit": { "max_size": 100000 }
+  }
+ }
+}

+ 36 - 0
dbschema.json

@@ -0,0 +1,36 @@
+{
+	"db_name": "ZeroMail",
+	"db_file": "data/users/zeromail.db",
+	"version": 2,
+	"maps": {
+		".+/data.json": {
+			"to_table": [
+				{"node": "message", "table": "message", "key_col": "date_added", "val_col": "encrypted"},
+				{"node": "secret", "table": "secret", "key_col": "date_added", "val_col": "encrypted"}
+			]
+		},
+		".+/content.json": {
+			"to_keyvalue": [ "cert_user_id" ]
+		}
+	},
+	"tables": {
+		"message": {
+			"cols": [
+				["date_added", "INTEGER"],
+				["encrypted", "TEXT"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX message_key ON message(json_id, date_added)"],
+			"schema_changed": 3
+		},
+		"secret": {
+			"cols": [
+				["date_added", "INTEGER"],
+				["encrypted", "TEXT"],
+				["json_id", "INTEGER REFERENCES json (json_id)"]
+			],
+			"indexes": ["CREATE UNIQUE INDEX secret_key ON secret(json_id, date_added)"],
+			"schema_changed": 3
+		}
+	}
+}

+ 55 - 0
echobot/echobot.py

@@ -0,0 +1,55 @@
+import os
+import time
+from selenium import webdriver
+
+PHANTOMJS_PATH = "tools/phantomjs/bin/phantomjs"
+SITE_URL = "http://127.0.0.1:43110"
+
+browser = webdriver.PhantomJS(executable_path=PHANTOMJS_PATH, service_log_path=os.path.devnull)
+browser.set_window_size(1400, 1000)
+
+browser.get("%s/1MaiL5gfBM1cyb4a8e3iiL8L5gXmoAJu27" % SITE_URL)
+print " * LocalStorage:", browser.execute_script("return JSON.stringify(localStorage)")
+
+# Switch to inner frame
+browser.switch_to.frame(browser.find_element_by_id("inner-iframe"))
+
+# Check if Inbox is active
+assert browser.execute_script("return window.Page.message_lists.getActive().title") == u"Inbox"
+
+# Setup new message checking script
+browser.execute_script("""
+window.replyUnreadMessage = function(cb) {
+	var messages = window.Page.message_lists.getActive().messages
+	var unread_messages = messages.filter(function(message) { return message.read == false })
+	if (unread_messages.length == 0) {
+		cb(false)
+		return false
+	}
+	message = unread_messages[0]
+	console.log("New unread message:", message.key)
+	message.handleListClick()
+	message.handleReplyClick()
+	Page.message_create.body = "This is an echo of your message:\\n> " + message.row.body.replace(/\\n/g, "\\n> ") + "\\n\\nGreetings:\\nThe ZeroMail echo bot"
+	Page.projector.scheduleRender()
+	setTimeout(function() {
+		Page.message_create.handleSendClick()
+		cb(true)
+	}, 1000)
+}
+
+window.replyTimer = function() {
+	setTimeout(function() { replyUnreadMessage(replyTimer) }, 1000)
+}
+
+replyTimer()
+""")
+
+browser.switch_to.default_content()
+last_log_line = 0
+while 1:
+	lines = browser.get_log("browser")
+	for line in lines[last_log_line:]:
+		print line["message"].replace("(:)", "")
+		last_log_line += 1
+	time.sleep(5)

BIN
img/loading.gif


+ 39 - 0
index.html

@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <title>ZeroMail_</title>
+ <meta charset="utf-8">
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <link rel="stylesheet" href="css/all.css" />
+ <base href="" target="_top" id="base">
+ <script>base.href = document.location.href.replace("/media", "").replace("index.html", "").replace(/[&?]wrapper=False/, "").replace(/[&?]wrapper_nonce=[A-Za-z0-9]+/, "")</script>
+</head>
+<body>
+
+<div id="banner" style="display: none">
+__________                     _____         .__.__   ​
+\____    /___________  ____   /     \ _____  |__|  |  ​
+  /     _/ __ \_  __ \/  _ \ /  \ /  \\__  \ |  |  |  ​
+ /     /\  ___/|  | \(  <_> /    Y    \/ __ \|  |  |__​
+/_______ \___  |__|   \____/\____|__  (____  |__|____/​
+        \/   \/                     \/     \/         ​
+
+END-TO-END ENCRYPTED P2P MESSAGING SYSTEM
+</div>
+
+<div id="Leftbar" class="Leftbar"></div>
+
+<div class="center">
+ <div id="MessageLists"></div>
+</div>
+
+<div class="right">
+ <div id="MessageShow" class="MessageShow"></div>
+ <div id="MessageCreate" class="MessageCreate empty minimized"></div>
+</div>
+
+<script type="text/javascript" src="js/all.js" asyc></script>
+
+</body>
+</html>

+ 90 - 0
js/Leftbar.coffee

@@ -0,0 +1,90 @@
+class Leftbar extends Class
+	constructor: ->
+		@contacts = []
+		@folder_active = "inbox"
+		@reload_contacts = true
+
+		@
+
+
+	handleContactClick: (e) =>
+		Page.message_create.show(e.currentTarget.querySelector(".name").textContent)
+		return false
+
+	handleNewMessageClick: (e) =>
+		Page.message_create.show()
+		return false
+
+	handleFolderClick: (e) =>
+		folder_name = e.currentTarget.href.replace(/.*\?/, "")
+		@folder_active = folder_name.toLowerCase()
+		Page.cmd "wrapperReplaceState", [{}, "", folder_name]
+		return false
+
+	handleLogoutClick: (e) =>
+		Page.cmd "certSelect", [["zeroid.bit"]]
+		# Reset localstorage
+		Page.local_storage = {}
+		Page.saveLocalStorage ->
+			Page.getLocalStorage()
+		return false
+
+
+	loadContacts: (cb) ->
+		addresses = (address for address of Page.local_storage.parsed.my_secret)
+		query = """
+			SELECT directory, value AS cert_user_id
+			FROM json
+			LEFT JOIN keyvalue USING (json_id)
+			WHERE ? AND file_name = 'content.json' AND key = 'cert_user_id'
+		"""
+		Page.cmd "dbQuery", [query, {directory: addresses}], (rows) ->
+			contacts = ([row.cert_user_id, row.directory] for row in rows)
+			cb contacts
+
+	getContacts: ->
+		if @reload_contacts
+			@reload_contacts = false
+			@log "Reloading contacts"
+			Page.on_local_storage.then =>
+				known_addresses = (address for [username, address] in @contacts)
+				unknown_addresses = (address for address of Page.local_storage.parsed.my_secret when address not in known_addresses)
+				if unknown_addresses.length > 0
+					@loadContacts (contacts) =>
+						@log "Unknown contacts found, reloaded."
+						@contacts = contacts
+						Page.projector.scheduleRender()
+
+		@contacts
+
+	render: =>
+		contacts = if Page.site_info?.cert_user_id then @getContacts() else []
+		h("div.Leftbar", [
+			h("a.logo", {href: "?Main"}, ["ZeroMail_"])
+			h("a.button-create.newmessage", {href: "#New+message", onclick: @handleNewMessageClick}, ["New message"])
+			h("div.folders", [
+				h("a", {key: "Inbox", href: "?Inbox", classes: {"active": @folder_active == "inbox"}, onclick: @handleFolderClick}, ["Inbox"])
+				h("a", {key: "Sent", href: "?Sent", classes: {"active": @folder_active == "sent"}, onclick: @handleFolderClick}, ["Sent"])
+			])
+			if contacts.length > 0
+				[
+					h("h2", ["Contacts"]),
+					h("div.contacts", contacts.map ([username, address]) =>
+						h("a.username", {key: username, href: Page.createUrl("to", username.replace("@zeroid.bit", "")), onclick: @handleContactClick, "enterAnimation": Animation.show}, [
+							h("span.bullet", {"style": "color: #{Text.toColor(address)}"}, ["•"]),
+							h("span.name", [username.replace("@zeroid.bit", "")])
+						])
+					)
+				]
+			if Page.site_info?.cert_user_id then h("a.logout.icon.icon-logout", {href: "?Logout", title: "Logout", onclick: @handleLogoutClick})
+		])
+
+
+	onSiteInfo: (site_info) ->
+		if site_info.event
+			[action, inner_path] = site_info.event
+			if action == "file_done" and inner_path.endsWith "data.json"
+				@reload_contacts = true
+
+
+window.Leftbar = Leftbar

+ 102 - 0
js/Message.coffee

@@ -0,0 +1,102 @@
+class Message
+	constructor: (@message_list, @row) ->
+		@active = false
+		@deleted = false
+		@key = @row.key
+		if @row.folder == "sent" or Page.local_storage.read[@row.date_added]
+			@read = true
+		else
+			@read = false
+		@
+
+
+	getBodyPreview: ->
+		return @row.body[0..80]
+
+	markRead: (read = true) ->
+		if not @read
+			Page.local_storage.read[@row.date_added] = true
+			Page.saveLocalStorage()
+		@read = read
+
+
+	# Handle
+
+	handleListClick: =>
+		@markRead()
+		@message_list.setActiveMessage(@)
+		Page.message_show.setMessage(@)
+		return false
+
+	handleDeleteClick: =>
+		@message_list.deleteMessage(@)
+		return false
+
+	handleReplyClick: =>
+		Page.message_create.setReplyDetails()
+		Page.message_create.show()
+		return false
+
+
+	# Render
+
+	renderBody: (node) =>
+		node.innerHTML = Text.renderMarked(@row.body, {"sanitize": true})
+
+
+	renderBodyPreview: (node) =>
+		node.textContent = @getBodyPreview()
+
+	handleContactClick: (e) =>
+		Page.message_create.show(e.currentTarget.querySelector(".name").textContent)
+		return false
+
+	renderUsernameLink: (username, address) =>
+		color = Text.toColor(address)
+		h("a.username", {href: Page.createUrl("to", username.replace("zeroid.bit", "")), onclick: @handleContactClick},
+			@renderUsername(username, address)
+		)
+
+	renderUsername: (username, address) =>
+		color = Text.toColor(address)
+		[
+			h("span.name", {"style": "color: #{color}"}, [username.replace("@zeroid.bit", "")])
+		]
+
+
+	renderList: ->
+		h("a.Message", {
+			"key": @key, "href": "#MessageShow:#{@row.key}",
+			"onclick": @handleListClick,
+			"enterAnimation": Animation.slideDown, "exitAnimation": Animation.slideUp,
+			classes: { "active": @active, "unread": !@read }
+			}, [
+				h("div.sent", [Time.since(@row.date_added)]),
+				h("div.subject", [@row.subject]),
+				if @row.folder == "sent"
+					h("div.to.username", ["To: ", @renderUsername(@row.to, @row.to_address)])
+				else
+					h("div.from.username", ["From: ", @renderUsername(@row.from, @row.from_address)])
+				,
+				h("div.preview", {"afterCreate": @renderBodyPreview, "updateAnimation": @renderBodyPreview}, [@row.body])
+			]
+		)
+
+	renderShow: =>
+		h("div.Message", {"key": @key, "enterAnimation": Animation.show, "classes": {"deleted": @deleted}}, [
+			h("div.tools", [
+				h("a.icon.icon-reply", {href: "#Reply", "title": "Reply message", onclick: @handleReplyClick}),
+				h("a.icon.icon-trash", {href: "#Delete", "title": "Delete message", onclick: @handleDeleteClick})
+			]),
+			h("div.subject", [@row.subject]),
+			h("div.sent", [Time.date(@row.date_added, "full")]),
+			if @row.folder == "sent"
+				h("div.to.username", ["To: ", @renderUsernameLink(@row.to, @row.to_address)])
+			else
+				h("div.from.username", ["From: ", @renderUsernameLink(@row.from, @row.from_address)])
+			,
+			h("div.body", {"afterCreate": @renderBody, "updateAnimation": @renderBody}, [@row.body])
+		])
+
+
+window.Message = Message

+ 182 - 0
js/MessageCreate.coffee

@@ -0,0 +1,182 @@
+class MessageCreate extends Class
+	constructor: ->
+		@subject = ""
+		@body = ""
+
+		@minimized = true
+		@sending = false
+		@just_sent = false
+		@user_address = {}  # Username -> address
+		@update_usernames = true
+
+		@field_to = new Autocomplete @getUsernames, {type: "text", placeholder: "Username", value: ""}
+		@node = null  # Html node of messageCreate
+
+
+	@property 'to',
+		get: -> @field_to.attrs.value
+		set: (to) -> @field_to.setValue(to)
+
+
+	isEmpty: =>
+		return not (@body + @subject + @to)
+
+
+	isFilled: =>
+		return (@body != "" and @subject != "" and @to != "" and Page.user.data)
+
+
+	setNode: (node) =>
+		@node = node
+
+
+	setReplyDetails: =>
+		current_message = Page.message_lists.message_active
+		if current_message.row.folder == "sent"
+			@to = current_message.row.to.replace("@zeroid.bit", "")
+		else
+			@to = current_message.row.from.replace("@zeroid.bit", "")
+		@subject = "Re: " + current_message.row.subject.replace("Re: ", "")
+
+
+	getUsernames: =>
+		return (username.replace("@zeroid.bit", "") for username, address of @user_address)
+
+
+	getTitle: =>
+		if @just_sent
+			title = "Message sent!"
+		else if @isEmpty()
+			if Page.message_lists.message_active
+				title = "Reply to this message"
+			else
+				title = "New message"
+		else
+			if @subject.startsWith "Re:"
+				title = "Reply to message"
+			else
+				title = "New message"
+
+		return title
+
+
+	updateUsernames: ->
+		@update_usernames = false
+		Page.users.getAll (user_address) =>
+			@user_address = user_address
+
+
+	show: (to, subject, body) =>
+		if to then @to = to
+		if subject then @subject = subject
+		if body then @body = body
+		@minimized = false
+		document.body.classList.add("MessageCreate-opened")
+		# Set focus to first empty field
+		if not @to then @node.querySelector(".to").focus()
+		else if not @subject then @node.querySelector(".subject").focus()
+		else if not @body then @node.querySelector(".body").focus()
+
+		if @update_usernames then @updateUsernames()
+		return false
+
+	hide: =>
+		document.body.classList.remove("MessageCreate-opened")
+		@minimized = true
+
+
+	handleTitleClick: (e) =>
+		e.cancelBubble = true
+		if @minimized
+			# Set reply default values
+			if @isEmpty() and Page.message_lists.message_active
+				@setReplyDetails()
+			@show()
+		else
+			@hide()
+
+		return false
+
+	handleCloseClick: (e) =>
+		e.cancelBubble = true
+		@hide()
+		@to = ""
+		@subject = ""
+		@body = ""
+		return false
+
+	handleInput: (e) =>
+		@[e.target.name] = e.target.value
+		return false
+
+	handleSendClick: (e) =>
+		to = @to
+		if to.indexOf("@") == -1
+			to += "@zeroid.bit"
+		if not @user_address[to]
+			to = ""
+			@node.querySelector(".to").focus()
+			return false
+		# Text-hiding animation
+		Animation.scramble @node.querySelector(".to")
+		Animation.scramble @node.querySelector(".subject")
+		Animation.scramble @node.querySelector(".body")
+		@sending = true
+		Page.user.getSecret @user_address[to], (aes_key) =>
+			if not aes_key
+				# User's publickey not found
+				@sending = false
+				return false
+			message = {"subject": @subject, "body": @body, "to": to}
+			@log "Sending", message
+			Page.cmd "aesEncrypt", [Text.jsonEncode(message), aes_key], (res) =>
+				[aes_key, iv, encrypted] = res
+				Page.user.data.message[Page.user.getNewIndex("message")] = iv+","+encrypted
+				Page.user.saveData().then (send_res) =>
+					@sending = false
+					if send_res
+						@hide()
+						@just_sent = true
+						if @user_address[message.to] == Page.site_info.auth_address
+							@log "Sent message to myself, reload inbox"
+							Page.message_lists.inbox.reload = true
+						setTimeout ( =>
+							@just_sent = false
+							@to = ""
+							@subject = ""
+							@body = ""
+							Page.leftbar.reload_contacts = true
+							Page.projector.scheduleRender()
+						), 4000
+					else
+						# Failed: Reset scrambled values to normal text
+						@node.querySelector(".to").value = @to
+						@node.querySelector(".subject").value = @subject
+						@node.querySelector(".body").value = @body
+
+		return false
+
+
+	render: =>
+		h("div.MessageCreate", {classes: { minimized: @minimized, empty: @isEmpty(), sent: @just_sent}, afterCreate: @setNode}, [
+			h("a.titlebar", {"href": "#New+message", onclick: @handleTitleClick}, [
+				h("span.text", [@getTitle()]),
+				h("span.buttons", [
+					h("a.minimize", {href: "#Minimize", onclick: @handleTitleClick}, ["_"]),
+					h("a.close", {href: "#Close", onclick: @handleCloseClick}, ["×"])
+				])
+			]),
+			h("label.label-to", ["To:"]),
+			@field_to.render(),
+			h("input.subject", {type: "text", placeholder: "Subject", name: "subject", value: @subject, oninput: @handleInput}),
+			h("textarea.body", {placeholder: "Message", name: "body", value: @body, oninput: @handleInput}),
+			h("a.button.button-submit.button-send", {href: "#Send", classes: {"disabled": not @isFilled(), "loading": @sending or @just_sent}, onclick: @handleSendClick}, ["Encrypt & Send message"])
+		])
+
+
+	onSiteInfo: (site_info) ->
+		if site_info.event?[0] == "file_done"
+			@update_usernames = true
+
+
+window.MessageCreate = MessageCreate

+ 63 - 0
js/MessageList.coffee

@@ -0,0 +1,63 @@
+class MessageList extends Class
+	constructor: (@message_lists) ->
+		@title = "Unknown"
+		@loading = false
+		@loaded = false
+		@messages = []
+		@message_db = {}
+
+	getMessages: ->
+		return @messages
+
+	setActiveMessage: (message) ->
+		if @message_lists.message_active
+			@message_lists.message_active.active = false
+		message.active = true
+		@message_lists.message_active = message
+
+
+	addMessage: (message_row, index=-1) ->
+		message = new Message(@, message_row)
+		@message_db[message_row.key] = message
+		if index >= 0
+			@messages.splice index, 0, message
+		else
+			@messages.push message
+
+
+	deleteMessage: (message) ->
+		message.deleted = true
+		index = @messages.indexOf(message)
+		if index > -1
+			@messages.splice(index, 1)
+
+	syncMessages: (message_rows) ->
+		@messages = []
+		for message_row in message_rows
+			current_obj = @message_db[message_row.key]
+			if current_obj
+				current_obj.row = message_row
+				@messages.push current_obj
+			else
+				@addMessage(message_row)
+
+	render: =>
+		messages = if Page.site_info?.cert_user_id then @getMessages() else []
+		if messages.length > 0
+			return h("div.MessageList", {"key": @title, "enterAnimation": Animation.show},
+				messages.map (message) ->
+					message.renderList()
+			)
+		else if @loading
+			return h("div.MessageList.empty", {"key": @title+".loading", "enterAnimation": Animation.show, "afterCreate": Animation.show, "delay": 1}, [
+				"#{@title}: Loading...",
+				h("span.cursor", ["_"])
+			])
+		else
+			return h("div.MessageList.empty", {"key": @title+".empty", "enterAnimation": Animation.show, "afterCreate": Animation.show}, [
+				"#{@title}: No messages",
+				h("span.cursor", ["_"])
+			])
+
+
+window.MessageList = MessageList

+ 181 - 0
js/MessageListInbox.coffee

@@ -0,0 +1,181 @@
+class MessageListInbox extends MessageList
+	constructor: ->
+		super
+		@reload = true
+		@loading = false
+		@messages = []
+		@my_aes_keys = {}
+		@title = "Inbox"
+
+
+	getParsedDb: (cb) ->
+		Page.on_local_storage.then =>
+			cb(Page.local_storage.parsed)
+
+
+	decryptKnownAesKeys: (parsed_db, cb) ->
+		load_keys = ([user_address, secret_id] for user_address, secret_id of parsed_db.my_secret if not @my_aes_keys[user_address])
+		if load_keys.length > 0
+			@log "Loading keys", load_keys
+			where = ("(directory = '#{user_address}' AND date_added = #{secret_id})" for [user_address, secret_id] in load_keys)
+			query = "SELECT * FROM secret LEFT JOIN json USING (json_id) WHERE #{where.join(' OR ')}"
+			Page.cmd "dbQuery", query, (rows) =>
+				Page.cmd "eciesDecrypt", [(row.encrypted for row in rows)], (decrypted_keys) =>
+					for decrypted_key, i in decrypted_keys
+						if not decrypted_key then continue
+						@my_aes_keys[rows[i].directory] = decrypted_key
+					cb(i)
+		else
+			cb(false)
+
+
+	decryptNewSecrets: (parsed_db, cb) ->
+		parsed_sql = []
+		known_addresses = []
+		for user_address, last_parsed of parsed_db.last_secret
+			parsed_sql.push("(directory = '#{user_address}' AND date_added > #{last_parsed})")
+			known_addresses.push("'#{user_address}'")
+
+		if known_addresses.length > 0
+			where = "WHERE #{parsed_sql.join(' OR ')} OR directory NOT IN (#{known_addresses.join(",")})"
+		else
+			where = ""
+
+		query = """
+			SELECT * FROM secret
+			LEFT JOIN json USING (json_id)
+			#{where}
+			ORDER BY date_added ASC
+		"""
+		Page.cmd "dbQuery", [query], (db_res) =>
+			if not db_res.length
+				cb(false)
+				return false
+
+			secrets = (row.encrypted for row in db_res)
+			Page.cmd "eciesDecrypt", [secrets], (aes_keys) =>
+				new_secrets = {}
+				for aes_key, i in aes_keys
+					db_row = db_res[i]
+					if aes_key  # Successfully decrypted key, assign it to user
+						new_secrets[db_row.directory] = db_row.date_added
+						parsed_db.my_secret[db_row.directory] = db_row.date_added
+						@my_aes_keys[db_row.directory] = aes_key
+					# Save last parsed messages id per user
+					parsed_db.last_secret[db_row.directory] = db_row.date_added
+				cb(new_secrets)
+
+
+	decryptNewMessages: (parsed_db, new_secrets, cb) ->
+		parsed_sql = []
+
+		for user_address, last_parsed of parsed_db.last_message
+			parsed_sql.push("(directory = '#{user_address}' AND date_added > #{last_parsed})")
+
+		new_addresses = []
+		for user_address, aes_key of @my_aes_keys
+			if not parsed_db.last_message[user_address]  # Secret shared, but no message parsed yet
+				new_addresses.push("'#{user_address}'")
+
+		if parsed_sql.length > 0
+			where = "WHERE #{parsed_sql.join(' OR ')} OR directory IN (#{new_addresses.join(",")})"
+		else
+			where = "WHERE directory IN (#{new_addresses.join(",")})"
+
+		query = """
+			SELECT * FROM message
+			LEFT JOIN json USING (json_id)
+			#{where}
+			ORDER BY date_added ASC
+		"""
+		found = 0
+		Page.cmd "dbQuery", [query], (db_res) =>
+			if db_res.length == 0
+				cb(found)
+				return
+			aes_keys = (aes_key for address, aes_key of @my_aes_keys)
+			encrypted_texts = (row.encrypted.split(",") for row in db_res)
+			Page.cmd "aesDecrypt", [encrypted_texts, aes_keys], (decrypted_texts) =>
+				for decrypted_text, i in decrypted_texts
+					db_row = db_res[i]
+					if not parsed_db.my_message[db_row.directory]
+						parsed_db.my_message[db_row.directory] = []
+					if decrypted_text and db_row.date_added not in parsed_db.my_message[db_row.directory]
+						parsed_db.my_message[db_row.directory].push(db_row.date_added)
+						found += 1
+					parsed_db.last_message[db_row.directory] = db_row.date_added
+				cb(found)
+
+
+	loadMessages: (parsed_db, cb) ->
+		my_message_ids = []
+		for address, ids of parsed_db.my_message
+			my_message_ids = my_message_ids.concat(ids)
+		query = """
+			SELECT message.*, json.directory, keyvalue.value AS username FROM message
+			LEFT JOIN json USING (json_id)
+			LEFT JOIN json AS json_content ON json_content.directory = json.directory AND json_content.file_name = "content.json"
+			LEFT JOIN keyvalue ON keyvalue.json_id = json_content.json_id AND keyvalue.key = "cert_user_id"
+			WHERE date_added IN (#{my_message_ids.join(",")}) AND date_added NOT IN (#{Page.local_storage.deleted.join(",")})
+			ORDER BY date_added DESC
+		"""
+		Page.cmd "dbQuery", [query], (db_rows) =>
+			aes_keys = (aes_key for address, aes_key of @my_aes_keys)
+			encrypted_messages = (row.encrypted.split(",") for row in db_rows)
+			Page.cmd "aesDecrypt", [encrypted_messages, aes_keys], (decrypted_messages) =>
+				message_rows = []
+				for decrypted_message, i in decrypted_messages
+					if not decrypted_message then continue
+					db_row = db_rows[i]
+					message_row = Text.jsonDecode(decrypted_message)
+					message_row.date_added = db_row.date_added
+					message_row.key = "inbox-#{db_row.directory}-#{message_row.date_added}"
+					message_row.message_id = db_row.date_added
+					message_row.from = db_row.username
+					message_row.from_address = db_row.directory
+					message_row.folder = "inbox"
+					message_rows.push(message_row)
+				@syncMessages(message_rows)
+				Page.projector.scheduleRender()
+				cb(message_rows)
+
+
+	getMessages: ->
+		if @reload and Page.site_info
+			@loading = true
+			@reload = false
+			@logStart "getMessages"
+			Page.on_local_storage.then =>
+				parsed_db = Page.local_storage.parsed
+				@decryptKnownAesKeys parsed_db, (loaded_keys) =>
+					@log "Loaded known AES keys", loaded_keys
+					@decryptNewSecrets parsed_db, (new_secrets) =>
+						@log "New secrets found", new_secrets
+						if not isEmpty(new_secrets)
+							Page.leftbar.reload_contacts = true
+						@decryptNewMessages parsed_db, new_secrets, (found) =>
+							@log "New messages found", found
+							if not found and @messages.length > 0
+								@logEnd "getMessages", "No new messages"
+								Page.local_storage.parsed = parsed_db
+								@loading = false
+								@loaded = true
+								return false
+							@loadMessages parsed_db, (message_rows) =>
+								@logEnd "getMessages", "Loaded messages", message_rows.length
+								Page.local_storage.parsed = parsed_db
+								Page.saveLocalStorage()
+								@loading = false
+								@loaded = true
+
+
+		return @messages
+
+
+	deleteMessage: (message) ->
+		super
+		if message.row.message_id not in Page.local_storage.deleted
+			Page.local_storage.deleted.push(message.row.message_id)
+			Page.saveLocalStorage()
+
+window.MessageListInbox = MessageListInbox

+ 60 - 0
js/MessageListSent.coffee

@@ -0,0 +1,60 @@
+class MessageListSent extends MessageList
+	constructor: ->
+		super
+		@reload = true
+		@loading = false
+		@messages = []
+		@title = "Sent"
+
+
+	getMessages: ->
+		if @reload and Page.site_info and Page.site_info.cert_user_id and not @loading
+			@reload = false
+			@loading = true
+			query = """
+				SELECT date_added, encrypted
+				FROM message
+				LEFT JOIN json USING (json_id)
+				WHERE ?
+				ORDER BY date_added DESC
+			"""
+			Page.cmd "dbQuery", [query, {"json.directory": Page.site_info.auth_address}], (db_rows) =>
+				encrypted_messages = (row.encrypted.split(",") for row in db_rows)
+				Page.user.getDecryptedSecretsSent (sent_secrets) =>
+					keys = (aes_key for address, aes_key of sent_secrets)
+
+					Page.cmd "aesDecrypt", [encrypted_messages, keys], (decrypted_messages) =>
+						message_rows = []
+						usernames = []
+						for decrypted_message, i in decrypted_messages
+							if not decrypted_message then continue
+							message_row = Text.jsonDecode(decrypted_message)
+							message_row.date_added = db_rows[i].date_added
+							message_row.key = "sent-"+message_row.date_added
+							message_row.message_id = db_rows[i].date_added
+							message_row.sender = "Unknown"
+							message_row.folder = "sent"
+							message_rows.push(message_row)
+							if message_row.to not in usernames
+								usernames.push(message_row.to)
+
+						Page.users.getAddress usernames, (addresses) =>
+							for message_row in message_rows
+								message_row.to_address = addresses[message_row.to]
+								message_row.to_address ?= ""
+							@syncMessages(message_rows)
+							Page.projector.scheduleRender()
+							@loading = false
+							@loaded = true
+
+		return @messages
+
+
+	deleteMessage: (message) ->
+		super
+		delete Page.user.data.message[message.row.message_id]
+		Page.user.saveData().then (res) =>
+			@log "Delete result", res
+
+
+window.MessageListSent = MessageListSent

+ 28 - 0
js/MessageLists.coffee

@@ -0,0 +1,28 @@
+class MessageLists extends Class
+	constructor: ->
+		@inbox = new MessageListInbox(@)
+		@sent = new MessageListSent(@)
+		@message_active = null
+
+
+	getActive: ->
+		return @[Page.leftbar.folder_active]
+
+	getActiveMessage: ->
+		return @getActive().message_active
+
+
+	render: =>
+		h("div.MessageLists", [@getActive().render()])
+
+
+	onSiteInfo: (site_info) ->
+		if site_info.event
+			[action, inner_path] = site_info.event
+			if action == "file_done" and inner_path == "data/users/#{site_info.auth_address}/data.json"
+				@sent.reload = true
+			if action == "file_done" and inner_path.endsWith "data.json"
+				@inbox.reload = true
+
+
+window.MessageLists = MessageLists

+ 24 - 0
js/MessageShow.coffee

@@ -0,0 +1,24 @@
+class MessageShow extends Class
+	constructor: ->
+		@message = null
+
+	setMessage: (message) ->
+		@message = message
+		Page.projector.scheduleRender()
+
+	render: =>
+		h("div.MessageShow", [
+			if Page.site_info and (not Page.site_info.cert_user_id or (not Page.user.data and Page.user.inited))
+				start_screen.renderNocert()
+			else if @message
+				@message.renderShow()
+			else if Page.message_lists.getActive().messages.length > 0 or not Page.message_lists.getActive().loaded
+				h("div")
+			else if Page.site_info?.cert_user_id and Page.user.loaded.result
+				start_screen.renderNomessage()
+			else
+				h("div")
+		])
+
+
+window.MessageShow = MessageShow

+ 105 - 0
js/StartScreen.coffee

@@ -0,0 +1,105 @@
+class StartScreen extends Class
+	constructor: ->
+		@
+
+	addDots: (s) ->
+		s = ".".repeat(18-s.length)+s
+
+
+	getTermLines: ->
+		lines = []
+		server_info = Page.server_info
+		site_info = Page.site_info
+
+		end_version = server_info.version+" r"+server_info.rev
+		if server_info.rev > 630
+			end_version += " [OK]"
+		else
+			end_version += " [FAIL]"
+
+		if site_info.bad_files == 0
+			end_publickeys = "[DONE]"
+		else
+			if site_info.workers > 0
+				percent = Math.round(100-(site_info.bad_files/site_info.started_task_num)*100)
+				end_publickeys = "[ #{percent}%]"
+			else
+				end_publickeys = "[BAD:#{site_info.bad_files}]"
+
+		end_messages = ""
+		if site_info.bad_files == 0
+			end_messages = "[DONE]"
+		else
+			if site_info.workers > 0
+				percent = Math.round(100-(site_info.bad_files/site_info.started_task_num)*100)
+				end_messages += "[ #{percent}%]"
+			else
+				end_messages += "[BAD:#{site_info.bad_files}]"
+
+		lines.push("Checking ZeroNet version.......................#{@addDots(end_version)}")
+		lines.push("Checking public keys in database...............#{@addDots(end_publickeys)}")
+		lines.push("Checking messages in database..................#{@addDots(end_messages)}")
+		lines.push("Checking current user's public key in database........[NOT FOUND]")
+		return lines.join("\n")
+
+
+	handleCertselect: ->
+		Page.cmd "certSelect", [["zeroid.bit"]]
+		return false
+
+	handleCreate: ->
+		Page.user.createData()
+		return false
+
+
+	renderBody: (node) =>
+		node.innerHTML = Text.renderMarked(node.textContent, {"sanitize": true})
+
+	renderNocert: =>
+		@log "renderNocert"
+		h("div.StartScreen.nocert", {"key": "nocert", "afterCreate": Animation.addVisibleClass, "exitAnimation": Animation.slideUp}, [
+			h("div.banner.term", {"afterCreate": Animation.termLines}, ["W E L C O M E   T O \n\n#{$('#banner').textContent}\n\n\n"]),
+			if Page.server_info and Page.site_info
+				h("div.term", {"afterCreate": Animation.termLines, "delay": 1, "delay_step": 0.2}, [@getTermLines()])
+			,
+			if Page.server_info and Page.site_info
+				if Page.server_info.rev < 630
+					h("a.button.button-submit.button-certselect.disabled", {"href": "#Update", "afterCreate": Animation.show, "delay": 0, "style": "margin-left: -150px"}, ["Please update your ZeroNet client!"])
+				else if not Page.site_info.cert_user_id
+					h("a.button.button-submit.button-certselect", {"key": "certselect", "href": "#Select+username", "afterCreate": Animation.show, "delay": 1, onclick: @handleCertselect}, ["Select username"])
+				else
+					[
+						h("div.term", {"key": "username-term", "afterCreate": Animation.termLines}, [
+							"Selected username: #{Page.site_info.cert_user_id}#{'.'.repeat(22-Page.site_info.cert_user_id.length)}......[NO MAILBOX FOUND]"
+						]),
+						h("a.button.button-submit.button-certselect", {"key": "create", "href": "#Create+data", "afterCreate": Animation.show, "delay": 1, onclick: @handleCreate}, ["Create my mailbox"])
+					]
+
+		])
+
+
+	renderNomessage: =>
+		@log "renderNomessage"
+		h("div.StartScreen.nomessage", {"key": "nomessage", "enterAnimation": Animation.slideDown}, [
+			h("div.subject", ["Successful registration!"]),
+			h("div.from", [
+				"From: ",
+				h("a.username", {"href": "#"}, "zeromail")
+			]),
+			h("div.body", {afterCreate: @renderBody}, [
+				"""
+				Hello #{Page.site_info.cert_user_id.replace(/@.*/, "")}!
+
+				Welcome to ZeroNet family, from now anyone able to message you in a simple and secure way.
+
+				To try this drop a message to our echobot@zeroid.bit and she will send it right back to you.
+
+				_Best reguards: The users of ZeroNet_
+
+				###### Ps: To keep you identity safe don't forget to backup your **data/users.json** file!
+				"""
+			])
+		])
+
+
+window.start_screen = new StartScreen()

+ 125 - 0
js/User.coffee

@@ -0,0 +1,125 @@
+class User extends Class
+	constructor: ->
+		@data = null
+
+		@loading = false
+		@inited = false  # First load try done
+		@loaded = new Promise()
+		@loaded.then (res) =>
+			@loading = false
+			@log "Loaded", res
+			Page.projector.scheduleRender()
+
+
+	getInnerPath: ->
+		return "data/users/#{Page.site_info.auth_address}/data.json"
+
+	# Find new avalible index
+	getNewIndex: (node) ->
+		new_index = Date.now()
+		for i in [0..100]  # Find a free index
+			if not @data[node][new_index+i]
+				return new_index+i
+
+
+	getDecryptedSecretsSent: (cb) ->
+		if not @data?.secrets_sent
+			cb false
+			return false
+
+		Page.cmd "eciesDecrypt", [@data.secrets_sent], (decrypted) =>
+			if decrypted
+				cb JSON.parse(decrypted)
+			else
+				cb false
+
+	getPublickey: (user_address, cb) ->
+		Page.cmd "fileGet", ["data/users/#{user_address}/data.json"], (res) =>
+			data = JSON.parse(res)
+			cb(data.publickey)
+
+	addSecret: (secrets_sent, user_address, cb) ->
+		@getPublickey user_address, (publickey) =>
+			if not publickey
+				cb false
+				return Page.cmd "wrapperNotification", ["error", "No publickey for user #{user_address}"]
+			Page.cmd "aesEncrypt", [""], (res) =>  # Generate new random AES key
+				[key, iv, encrypted] = res
+				Page.cmd "eciesEncrypt", [key, publickey], (secret) =>  # Encrypt the new key for remote user
+					# Add for remote user
+					@data.secret[@getNewIndex("secret")] = secret
+					# Add key for me
+					secrets_sent[user_address] = key
+					Page.cmd "eciesEncrypt", [JSON.stringify(secrets_sent)], (secrets_sent_encrypted) =>
+						if not secrets_sent_encrypted
+							return cb false
+						@data["secrets_sent"] = secrets_sent_encrypted
+						cb key
+
+	getSecret: (user_address, cb) ->
+		@getDecryptedSecretsSent (secrets_sent) =>
+			if not secrets_sent
+				secrets_sent = {}
+			if secrets_sent[user_address]  # Already exits
+				return cb(secrets_sent[user_address])
+			else
+				@log "Creating new secret for #{user_address}"
+				@addSecret secrets_sent, user_address, (aes_key) ->
+					cb aes_key
+
+
+	loadData: (cb) ->
+		inner_path = @getInnerPath()
+		@log "Loading user file", inner_path
+		Page.cmd "fileGet", {"inner_path": inner_path, "required": false}, (get_res) =>
+			if get_res
+				@data = JSON.parse(get_res)
+				@loaded.resolve()
+				if cb then cb(true)
+			else
+				if cb then cb(false)
+			@inited = true
+
+	createData: ->
+		inner_path = @getInnerPath()
+		@log "Creating user file", inner_path
+		# First visit, no public key yet
+		@data = {"secret": {}, "secrets_sent": "", "publickey": null, "message": {}, "date_added": Date.now()}
+		Page.cmd "userPublickey", [], (publickey_res) =>
+			if publickey_res.error
+				Page.cmd "wrapperNotification", ["error", "Publickey read error: #{publickey_res.error}"]
+				@loaded.fail()
+			else
+				@data.publickey = publickey_res
+				@saveData().then (save_res) =>
+					@loaded.resolve(save_res)
+
+	saveData: (publish=true) ->
+		promise = new Promise()
+		inner_path = @getInnerPath()
+		Page.cmd "fileWrite", [inner_path, Text.fileEncode(@data)], (write_res) =>
+			if write_res != "ok"
+				Page.cmd "wrapperNotification", ["error", "File write error: #{write_res}"]
+				promise.fail()
+				return false
+			Page.cmd "sitePublish", {"inner_path": inner_path}, (publish_res) =>
+				if publish_res == "ok"
+					Page.message_lists.sent.reload = true
+					Page.projector.scheduleRender()
+					promise.resolve()
+				else
+					promise.resolve()
+		return promise
+
+
+	onSiteInfo: (site_info) ->
+		if not @loading and site_info.event and site_info.event[0] == "file_done" and site_info.event[1] == @getInnerPath()  # User file downloaded
+			@loadData()
+		if not @data and not @loading and site_info.cert_user_id and (not site_info.event or site_info.event?[0] == "cert_changed")  # Startup or changed user
+			@loadData()
+		if not site_info.cert_user_id  # Logged out
+			@data = null
+
+
+
+window.User = User

+ 32 - 0
js/Users.coffee

@@ -0,0 +1,32 @@
+class Users extends Class
+	constructor: ->
+		@user_address = {}
+
+
+	getAddress: (usernames, cb) ->
+		unknown_address = (username for username in usernames when not @user_address[username]?)
+		if unknown_address.length == 0
+			cb(@user_address)
+			return
+		query = """
+			SELECT value, directory
+			FROM keyvalue
+			LEFT JOIN json USING (json_id)
+			WHERE ?
+		"""
+		Page.cmd "dbQuery", [query, {"key": "cert_user_id", "value": unknown_address}], (rows) =>
+			for row in rows
+				@user_address[row["value"]] = row["directory"]
+			cb(@user_address)
+
+
+	getAll: (cb) ->
+		Page.cmd "dbQuery", ["SELECT value, directory FROM keyvalue LEFT JOIN json USING (json_id) WHERE key = 'cert_user_id'"], (rows) =>
+			if rows.error then return false
+			@user_address = {}
+			for row in rows
+				@user_address[row["value"]] = row["directory"]
+			cb @user_address
+
+
+window.Users = Users

+ 111 - 0
js/ZeroMail.coffee

@@ -0,0 +1,111 @@
+window.h = maquette.h
+
+class ZeroMail extends ZeroFrame
+	init: ->
+		@params = {}
+		@site_info = null
+		@on_site_info = new Promise()
+		@on_local_storage = new Promise()
+		@server_info = null
+		@user = new User()
+		@users = new Users()
+		@local_storage = null
+
+	createProjector: ->
+		@leftbar = new Leftbar()
+		@message_lists = new MessageLists()
+		@message_show = new MessageShow()
+		@message_create = new MessageCreate()
+		@route(base.href.replace(/.*?\?/, ""), document.location.hash)
+		@projector = maquette.createProjector()
+		@projector.replace($("#MessageLists"), @message_lists.render)
+		@projector.replace($("#MessageShow"), @message_show.render)
+		@projector.replace($("#Leftbar"), @leftbar.render)
+		@projector.merge($("#MessageCreate"), @message_create.render)
+
+		# Update every minute to keep time since fields up-to date
+		setInterval ( ->
+			Page.projector.scheduleRender()
+		), 60*1000
+
+
+	# Route site urls
+	route: (query) ->
+		@params = Text.parseQuery(query)
+		@log "Route", @params
+		if @params.to
+			@message_create.show(@params.to)
+			@cmd "wrapperReplaceState", [{}, "", @createUrl("to", "")]  # Remove to parameter from url
+		if @params.url == "Sent"
+			@leftbar.folder_active = "sent"
+
+	# Add/remove/change parameter to current site url
+	createUrl: (key, val) ->
+		params = JSON.parse(JSON.stringify(@params))  # Clone
+		if typeof key == "Object"
+			vals = key
+			for key, val of keys
+				params[key] = val
+		else
+			params[key] = val
+		return "?"+Text.encodeQuery(params)
+
+
+	getLocalStorage: ->
+		@on_site_info.then =>
+			@cmd "wrapperGetLocalStorage", [], (@local_storage) =>
+				@local_storage ?= {}
+				@local_storage.read ?= {}
+				@local_storage.deleted ?= []
+				@local_storage.parsed ?= {}
+				@local_storage.parsed.last_secret ?= {}  # Last parsed secrets: {user_address: last_parsed_secret_id, ...}
+				@local_storage.parsed.last_message ?= {}  # Last parsed messages: {user_address: last_parsed_message_id, ...}
+				@local_storage.parsed.my_secret ?= {}  # Secrets sent to me: {user_address: secret_id}
+				@local_storage.parsed.my_message ?= {}  # Decrypted messages: {user_address: [message_id, ...], ...}
+				@on_local_storage.resolve(@local_storage)
+
+	saveLocalStorage: (cb) ->
+		if @local_storage
+			@cmd "wrapperSetLocalStorage", @local_storage, (res) =>
+				if cb then cb(res)
+
+
+	onOpenWebsocket: (e) =>
+		@cmd "siteInfo", {}, (site_info) =>
+			@setSiteInfo(site_info)
+		@cmd "serverInfo", {}, (server_info) =>
+			@setServerInfo(server_info)
+
+
+	# Parse incoming requests from UiWebsocket server
+	onRequest: (cmd, params) ->
+		if cmd == "setSiteInfo" # Site updated
+			@setSiteInfo(params)
+		else
+			@log "Unknown command", params
+
+
+	setSiteInfo: (site_info) ->
+		@site_info = site_info
+
+		if site_info.event?[0] == "cert_changed"
+			@getLocalStorage()
+
+		@leftbar.onSiteInfo(site_info)
+		@user.onSiteInfo(site_info)
+		@message_create.onSiteInfo(site_info)
+		@message_lists.onSiteInfo(site_info)
+
+		@projector.scheduleRender()
+		@getLocalStorage()
+		@on_site_info.resolve()
+
+	setServerInfo: (server_info) ->
+		@server_info = server_info
+		@projector.scheduleRender()
+
+
+window.Page = new ZeroMail()
+setTimeout ( ->
+	window.Page.createProjector()
+), 1

File diff suppressed because it is too large
+ 1127 - 0
js/all.js


+ 74 - 0
js/lib/Promise.coffee

@@ -0,0 +1,74 @@
+# From: http://dev.bizo.com/2011/12/promises-in-javascriptcoffeescript.html
+
+class Promise
+	@when: (tasks...) ->
+		num_uncompleted = tasks.length
+		args = new Array(num_uncompleted)
+		promise = new Promise()
+
+		for task, task_id in tasks
+			((task_id) ->
+				task.then(() ->
+					args[task_id] = Array.prototype.slice.call(arguments)
+					num_uncompleted--
+					promise.complete.apply(promise, args) if num_uncompleted == 0
+				)
+			)(task_id)
+
+		return promise
+
+	constructor: ->
+		@resolved = false
+		@end_promise = null
+		@result = null
+		@callbacks = []
+
+	resolve: ->
+		if @resolved
+			return false
+		@resolved = true
+		@data = arguments
+		if not arguments.length
+			@data = [true]
+		@result = @data[0]
+		for callback in @callbacks
+			back = callback.apply callback, @data
+		if @end_promise
+			@end_promise.resolve(back)
+
+	fail: ->
+		@resolve(false)
+
+	then: (callback) ->
+		if @resolved == true
+			callback.apply callback, @data
+			return
+
+		@callbacks.push callback
+
+		@end_promise = new Promise()
+
+window.Promise = Promise
+
+###
+s = Date.now()
+log = (text) ->
+	console.log Date.now()-s, Array.prototype.slice.call(arguments).join(", ")
+
+log "Started"
+
+cmd = (query) ->
+	p = new Promise()
+	setTimeout ( ->
+		p.resolve query+" Result"
+	), 100
+	return p
+
+back = cmd("SELECT * FROM message").then (res) ->
+	log res
+	return "Return from query"
+.then (res) ->
+	log "Back then", res
+
+log "Query started", back
+###

+ 2 - 0
js/lib/Property.coffee

@@ -0,0 +1,2 @@
+Function::property = (prop, desc) ->
+	Object.defineProperty @prototype, prop, desc

+ 996 - 0
js/lib/maquette.js

@@ -0,0 +1,996 @@
+(function (global) {
+
+  "use strict";
+
+
+  // Utilities
+
+  var emptyArray = [];
+
+  var extend = function (base, overrides) {
+    var result = {};
+    Object.keys(base).forEach(function (key) {
+      result[key] = base[key];
+    });
+    if(overrides) {
+      Object.keys(overrides).forEach(function (key) {
+        result[key] = overrides[key];
+      });
+    }
+    return result;
+  };
+
+  // Hyperscript helper functions
+
+  var appendChildren = function (parentSelector, insertions, main) {
+    for(var i = 0; i < insertions.length; i++) {
+      var item = insertions[i];
+      if(Array.isArray(item)) {
+        appendChildren(parentSelector, item, main);
+      } else {
+        if(item !== null && item !== undefined) {
+          if(!item.hasOwnProperty("vnodeSelector")) {
+            item = toTextVNode(item);
+          }
+          main.push(item);
+        }
+      }
+    }
+  };
+
+  var toTextVNode = function (data) {
+    return {
+      vnodeSelector: "",
+      properties: undefined,
+      children: undefined,
+      text: (data === null || data === undefined) ? "" : data.toString(),
+      domNode: null
+    };
+  };
+
+  // Render helper functions
+
+  var missingTransition = function() {
+    throw new Error("Provide a transitions object to the projectionOptions to do animations");
+  };
+
+  var defaultProjectionOptions = {
+    namespace: undefined,
+    eventHandlerInterceptor: undefined,
+    styleApplyer: function(domNode, styleName, value) {
+      // Provides a hook to add vendor prefixes for browsers that still need it.
+      domNode.style[styleName] = value;
+    },
+    transitions: {
+      enter: missingTransition,
+      exit: missingTransition
+    }
+  };
+
+  var applyDefaultProjectionOptions = function (projectionOptions) {
+    return extend(defaultProjectionOptions, projectionOptions);
+  };
+
+  var setProperties = function (domNode, properties, projectionOptions) {
+    if(!properties) {
+      return;
+    }
+    var eventHandlerInterceptor = projectionOptions.eventHandlerInterceptor;
+    for(var propName in properties) {
+      var propValue = properties[propName];
+      if(propName === "class" || propName === "className" || propName === "classList") {
+        throw new Error("Property " + propName + " is not supported, use 'classes' instead.");
+      } else if(propName === "classes") {
+        // object with string keys and boolean values
+        for(var className in propValue) {
+          if(propValue[className]) {
+            domNode.classList.add(className);
+          }
+        }
+      } else if(propName === "styles") {
+        // object with string keys and string (!) values
+        for(var styleName in propValue) {
+          var styleValue = propValue[styleName];
+          if(styleValue) {
+            if(typeof styleValue !== "string") {
+              throw new Error("Style values may only be strings");
+            }
+            projectionOptions.styleApplyer(domNode, styleName, styleValue);
+          }
+        }
+      } else if(propName === "key") {
+        continue;
+      } else if(propValue === null || propValue === undefined) {
+        continue;
+      } else {
+        var type = typeof propValue;
+        if(type === "function") {
+          if(eventHandlerInterceptor && (propName.lastIndexOf("on", 0) === 0)) { // lastIndexOf(,0)===0 -> startsWith
+            propValue = eventHandlerInterceptor(propName, propValue, domNode, properties); // intercept eventhandlers
+            if(propName === "oninput") {
+              (function () {
+                // record the evt.target.value, because IE sometimes does a requestAnimationFrame between changing value and running oninput
+                var oldPropValue = propValue;
+                propValue = function (evt) {
+                  evt.target["oninput-value"] = evt.target.value;
+                  oldPropValue.apply(this, [evt]);
+                };
+              }());
+            }
+          }
+          domNode[propName] = propValue;
+        } else if(type === "string" && propName !== "value") {
+          domNode.setAttribute(propName, propValue);
+        } else {
+          domNode[propName] = propValue;
+        }
+      }
+    }
+  };
+
+  var updateProperties = function (domNode, previousProperties, properties, projectionOptions) {
+    if(!properties) {
+      return;
+    }
+    var propertiesUpdated = false;
+    for(var propName in properties) {
+      // assuming that properties will be nullified instead of missing is by design
+      var propValue = properties[propName];
+      var previousValue = previousProperties[propName];
+      if(propName === "classes") {
+        var classList = domNode.classList;
+        for(var className in propValue) {
+          var on = !!propValue[className];
+          var previousOn = !!previousValue[className];
+          if(on === previousOn) {
+            continue;
+          }
+          propertiesUpdated = true;
+          if(on) {
+            classList.add(className);
+          } else {
+            classList.remove(className);
+          }
+        }
+      } else if(propName === "styles") {
+        for(var styleName in propValue) {
+          var newStyleValue = propValue[styleName];
+          var oldStyleValue = previousValue[styleName];
+          if(newStyleValue === oldStyleValue) {
+            continue;
+          }
+          propertiesUpdated = true;
+          if(newStyleValue) {
+            if(typeof newStyleValue !== "string") {
+              throw new Error("Style values may only be strings");
+            }
+            projectionOptions.styleApplyer(domNode, styleName, newStyleValue);
+          } else {
+            projectionOptions.styleApplyer(domNode, styleName, "");
+          }
+        }
+      } else {
+        if(!propValue && typeof previousValue === "string") {
+          propValue = "";
+        }
+        if(propName === "value") { // value can be manipulated by the user directly and using event.preventDefault() is not an option
+          if(domNode[propName] !== propValue && domNode["oninput-value"] !== propValue) {
+            domNode[propName] = propValue; // Reset the value, even if the virtual DOM did not change
+            domNode["oninput-value"] = undefined;
+          } // else do not update the domNode, otherwise the cursor position would be changed
+          if(propValue !== previousValue) {
+            propertiesUpdated = true;
+          }
+        } else if(propValue !== previousValue) {
+          var type = typeof propValue;
+          if(type === "function") {
+            throw new Error("Functions may not be updated on subsequent renders (property: " + propName +
+              "). Hint: declare event handler functions outside the render() function.");
+          }
+          if(type === "string") {
+            domNode.setAttribute(propName, propValue);
+          } else {
+            if(domNode[propName] !== propValue) { // Comparison is here for side-effects in Edge with scrollLeft and scrollTop
+              domNode[propName] = propValue;
+            }
+          }
+          propertiesUpdated = true;
+        }
+      }
+    }
+    return propertiesUpdated;
+  };
+
+  var addChildren = function (domNode, children, projectionOptions) {
+    if(!children) {
+      return;
+    }
+    for(var i = 0; i < children.length; i++) {
+      createDom(children[i], domNode, undefined, projectionOptions);
+    }
+  };
+
+  var same = function (vnode1, vnode2) {
+    if(vnode1.vnodeSelector !== vnode2.vnodeSelector) {
+      return false;
+    }
+    if(vnode1.properties && vnode2.properties) {
+      return vnode1.properties.key === vnode2.properties.key;
+    }
+    return !vnode1.properties && !vnode2.properties;
+  };
+
+  var findIndexOfChild = function (children, sameAs, start) {
+    if(sameAs.vnodeSelector !== "") {
+      // Never scan for text-nodes
+      for(var i = start; i < children.length; i++) {
+        if(same(children[i], sameAs)) {
+          return i;
+        }
+      }
+    }
+    return -1;
+  };
+
+  var nodeAdded = function (vNode, transitions) {
+    if(vNode.properties) {
+      var enterAnimation = vNode.properties.enterAnimation;
+      if(enterAnimation) {
+        if(typeof enterAnimation === "function") {
+          enterAnimation(vNode.domNode, vNode.properties);
+        } else {
+          transitions.enter(vNode.domNode, vNode.properties, enterAnimation);
+        }
+      }
+    }
+  };
+
+  var nodeToRemove = function (vNode, transitions) {
+    var domNode = vNode.domNode;
+    if(vNode.properties) {
+      var exitAnimation = vNode.properties.exitAnimation;
+      if(exitAnimation) {
+        domNode.style.pointerEvents = "none";
+        var removeDomNode = function () {
+          if(domNode.parentNode) {
+            domNode.parentNode.removeChild(domNode);
+          }
+        };
+        if(typeof exitAnimation === "function") {
+          exitAnimation(domNode, removeDomNode, vNode.properties);
+          return;
+        } else {
+          transitions.exit(vNode.domNode, vNode.properties, exitAnimation, removeDomNode);
+          return;
+        }
+      }
+    }
+    if(domNode.parentNode) {
+      domNode.parentNode.removeChild(domNode);
+    }
+  };
+
+  var checkDistinguishable = function(childNodes, indexToCheck, parentVNode, operation) {
+    var childNode = childNodes[indexToCheck];
+    if (childNode.vnodeSelector === "") {
+      return; // Text nodes need not be distinguishable
+    }
+    var key = childNode.properties ? childNode.properties.key : undefined;
+    if (!key) { // A key is just assumed to be unique
+      for (var i = 0; i < childNodes.length; i++) {
+        if (i !== indexToCheck) {
+          var node = childNodes[i];
+          if (same(node, childNode)) {
+            if (operation === "added") {
+              throw new Error(parentVNode.vnodeSelector + " had a " + childNode.vnodeSelector + " child " +
+                "added, but there is now more than one. You must add unique key properties to make them distinguishable.");
+            } else {
+              throw new Error(parentVNode.vnodeSelector + " had a " + childNode.vnodeSelector + " child " +
+                "removed, but there were more than one. You must add unique key properties to make them distinguishable.");
+            }
+          }
+        }
+      }
+    }
+  };
+
+  var updateChildren = function (vnode, domNode, oldChildren, newChildren, projectionOptions) {
+    if(oldChildren === newChildren) {
+      return false;
+    }
+    oldChildren = oldChildren || emptyArray;
+    newChildren = newChildren || emptyArray;
+    var oldChildrenLength = oldChildren.length;
+    var newChildrenLength = newChildren.length;
+    var transitions = projectionOptions.transitions;
+
+    var oldIndex = 0;
+    var newIndex = 0;
+    var i;
+    var textUpdated = false;
+    while(newIndex < newChildrenLength) {
+      var oldChild = (oldIndex < oldChildrenLength) ? oldChildren[oldIndex] : undefined;
+      var newChild = newChildren[newIndex];
+      if(oldChild !== undefined && same(oldChild, newChild)) {
+        textUpdated = updateDom(oldChild, newChild, projectionOptions) || textUpdated;
+        oldIndex++;
+      } else {
+        var findOldIndex = findIndexOfChild(oldChildren, newChild, oldIndex + 1);
+        if(findOldIndex >= 0) {
+          // Remove preceding missing children
+          for(i = oldIndex; i < findOldIndex; i++) {
+            nodeToRemove(oldChildren[i], transitions);
+            checkDistinguishable(oldChildren, i, vnode, "removed");
+          }
+          textUpdated = updateDom(oldChildren[findOldIndex], newChild, projectionOptions) || textUpdated;
+          oldIndex = findOldIndex + 1;
+        } else {
+          // New child
+          createDom(newChild, domNode, (oldIndex < oldChildrenLength) ? oldChildren[oldIndex].domNode : undefined, projectionOptions);
+          nodeAdded(newChild, transitions);
+          checkDistinguishable(newChildren, newIndex, vnode, "added");
+        }
+      }
+      newIndex++;
+    }
+    if(oldChildrenLength > oldIndex) {
+      // Remove child fragments
+      for(i = oldIndex; i < oldChildrenLength; i++) {
+        nodeToRemove(oldChildren[i], transitions);
+        checkDistinguishable(oldChildren, i, vnode, "removed");
+      }
+    }
+    return textUpdated;
+  };
+
+  var createDom = function (vnode, parentNode, insertBefore, projectionOptions) {
+    var domNode, i, c, start = 0, type, found;
+    var vnodeSelector = vnode.vnodeSelector;
+    if(vnodeSelector === "") {
+      domNode = vnode.domNode = document.createTextNode(vnode.text);
+      if(insertBefore !== undefined) {
+        parentNode.insertBefore(domNode, insertBefore);
+      } else {
+        parentNode.appendChild(domNode);
+      }
+    } else {
+      for (i = 0; i <= vnodeSelector.length; ++i) {
+        c = vnodeSelector.charAt(i);
+        if (i === vnodeSelector.length || c === '.' || c === '#') {
+          type = vnodeSelector.charAt(start - 1);
+          found = vnodeSelector.slice(start, i);
+          if (type === ".") {
+            domNode.classList.add(found);
+          } else if (type === "#") {
+            domNode.id = found;
+          } else {
+            if (found === "svg") {
+              projectionOptions = extend(projectionOptions, { namespace: "http://www.w3.org/2000/svg" });
+            }
+            if (projectionOptions.namespace !== undefined) {
+              domNode = vnode.domNode = document.createElementNS(projectionOptions.namespace, found);
+            } else {
+              domNode = vnode.domNode = document.createElement(found);
+            }
+            if (insertBefore !== undefined) {
+              parentNode.insertBefore(domNode, insertBefore);
+            } else {
+              parentNode.appendChild(domNode);
+            }
+          }
+          start = i + 1;
+        }
+      }
+      initPropertiesAndChildren(domNode, vnode, projectionOptions);
+    }
+  };
+
+  var initPropertiesAndChildren = function (domNode, vnode, projectionOptions) {
+    addChildren(domNode, vnode.children, projectionOptions); // children before properties, needed for value property of <select>.
+    if(vnode.text) {
+      domNode.textContent = vnode.text;
+    }
+    setProperties(domNode, vnode.properties, projectionOptions);
+    if(vnode.properties && vnode.properties.afterCreate) {
+      vnode.properties.afterCreate(domNode, projectionOptions, vnode.vnodeSelector, vnode.properties, vnode.children);
+    }
+  };
+
+  var updateDom = function (previous, vnode, projectionOptions) {
+    var domNode = previous.domNode;
+    if(!domNode) {
+      throw new Error("previous node was not rendered");
+    }
+    var textUpdated = false;
+    if(previous === vnode) {
+      return textUpdated; // we assume that nothing has changed
+    }
+    var updated = false;
+    if(vnode.vnodeSelector === "") {
+      if(vnode.text !== previous.text) {
+        domNode.nodeValue = vnode.text;
+        textUpdated = true;
+      }
+    } else {
+      if(vnode.vnodeSelector.lastIndexOf("svg", 0) === 0) { // lastIndexOf(needle,0)===0 means StartsWith
+        projectionOptions = extend(projectionOptions, { namespace: "http://www.w3.org/2000/svg" });
+      }
+      if(previous.text !== vnode.text) {
+        updated = true;
+        if(vnode.text === undefined) {
+          domNode.removeChild(domNode.firstChild); // the only textnode presumably
+        } else {
+          domNode.textContent = vnode.text;
+        }
+      }
+      updated = updateChildren(vnode, domNode, previous.children, vnode.children, projectionOptions) || updated;
+      updated = updateProperties(domNode, previous.properties, vnode.properties, projectionOptions) || updated;
+      if(vnode.properties && vnode.properties.afterUpdate) {
+        vnode.properties.afterUpdate(domNode, projectionOptions, vnode.vnodeSelector, vnode.properties, vnode.children);
+      }
+    }
+    if(updated && vnode.properties && vnode.properties.updateAnimation) {
+      vnode.properties.updateAnimation(domNode, vnode.properties, previous.properties);
+    }
+    vnode.domNode = previous.domNode;
+    return textUpdated;
+  };
+
+  /**
+   * Represents a {@link VNode} tree that has been rendered to a real DOM tree.
+   * @interface Projection
+   */
+  var createProjection = function (vnode, projectionOptions) {
+    if(!vnode.vnodeSelector) {
+      throw new Error("Invalid vnode argument");
+    }
+    return {
+      /**
+       * Updates the projection with the new virtual DOM tree.
+       * @param {VNode} updatedVnode - The updated virtual DOM tree. Note: The selector for the root of the tree must remain constant.
+       * @memberof Projection#
+       */
+      update: function (updatedVnode) {
+        if(vnode.vnodeSelector !== updatedVnode.vnodeSelector) {
+          throw new Error("The selector for the root VNode may not be changed. (consider using dom.merge and add one extra level to the virtual DOM)");
+        }
+        updateDom(vnode, updatedVnode, projectionOptions);
+        vnode = updatedVnode;
+      },
+      /**
+       * The DOM node that is used as the root of this {@link Projection}.
+       * @type {Element}
+       * @memberof Projection#
+       */
+      domNode: vnode.domNode
+    };
+  };
+
+  // Declaration of interfaces and callbacks, before the @exports maquette
+
+  /**
+   * A virtual representation of a DOM Node. Maquette assumes that {@link VNode} objects are never modified externally.
+   * Instances of {@link VNode} can be created using {@link module:maquette.h}.
+   * @interface VNode
+   */
+
+  /**
+   * A CalculationCache object remembers the previous outcome of a calculation along with the inputs.
+   * On subsequent calls the previous outcome is returned if the inputs are identical.
+   * This object can be used to bypass both rendering and diffing of a virtual DOM subtree.
+   * Instances of {@link CalculationCache} can be created using {@link module:maquette.createCache}.
+   * @interface CalculationCache
+   */
+
+  /**
+   * Keeps an array of result objects synchronized with an array of source objects.
+   * Mapping provides a {@link Mapping#map} function that updates the {@link Mapping#results}.
+   * The {@link Mapping#map} function can be called multiple times and the results will get created, removed and updated accordingly.
+   * A {@link Mapping} can be used to keep an array of components (objects with a `renderMaquette` method) synchronized with an array of data.
+   * Instances of {@link Mapping} can be created using {@link module:maquette.createMapping}.
+   * @interface Mapping
+   */
+
+  /**
+   * Used to create and update the DOM.
+   * Use {@link Projector#append}, {@link Projector#merge}, {@link Projector#insertBefore} and {@link Projector#replace}
+   * to create the DOM.
+   * The `renderMaquetteFunction` callbacks will be called immediately to create the DOM. Afterwards, these functions
+   * will be called again to update the DOM on the next animation-frame after:
+   *
+   *  - The {@link Projector#scheduleRender} function  was called
+   *  - An event handler (like `onclick`) on a rendered {@link VNode} was called.
+   *
+   * The projector stops when {@link Projector#stop} is called or when an error is thrown during rendering.
+   * It is possible to use `window.onerror` to handle these errors.
+   * Instances of {@link Projector} can be created using {@link module:maquette.createProjector}.
+   * @interface Projector
+   */
+
+  /**
+   * @callback enterAnimationCallback
+   * @param {Element} element - Element that was just added to the DOM.
+   * @param {Object} properties - The properties object that was supplied to the {@link module:maquette.h} method
+   */
+
+  /**
+   * @callback exitAnimationCallback
+   * @param {Element} element - Element that ought to be removed from to the DOM.
+   * @param {function(Element)} removeElement - Function that removes the element from the DOM.
+   * This argument is supplied purely for convenience.
+   * You may use this function to remove the element when the animation is done.
+   * @param {Object} properties - The properties object that was supplied to the {@link module:maquette.h} method that rendered this {@link VNode} the previous time.
+   */
+
+  /**
+   * @callback updateAnimationCallback
+   * @param {Element} element - Element that was modified in the DOM.
+   * @param {Object} properties - The last properties object that was supplied to the {@link module:maquette.h} method
+   * @param {Object} previousProperties - The previous properties object that was supplied to the {@link module:maquette.h} method
+   */
+
+  /**
+   * @callback afterCreateCallback
+   * @param {Element} element - The element that was added to the DOM.
+   * @param {Object} projectionOptions - The projection options that were used see {@link module:maquette.createProjector}.
+   * @param {string} vnodeSelector - The selector passed to the {@link module:maquette.h} function.
+   * @param {Object} properties - The properties passed to the {@link module:maquette.h} function.
+   * @param {VNode[]} children - The children that were created.
+   * @param {Object} properties - The last properties object that was supplied to the {@link module:maquette.h} method
+   * @param {Object} previousProperties - The previous properties object that was supplied to the {@link module:maquette.h} method
+   */
+
+  /**
+   * @callback afterUpdateCallback
+   * @param {Element} element - The element that may have been updated in the DOM.
+   * @param {Object} projectionOptions - The projection options that were used see {@link module:maquette.createProjector}.
+   * @param {string} vnodeSelector - The selector passed to the {@link module:maquette.h} function.
+   * @param {Object} properties - The properties passed to the {@link module:maquette.h} function.
+   * @param {VNode[]} children - The children for this node.
+   */
+
+  /**
+   * Contains simple low-level utility functions to manipulate the real DOM. The singleton instance is available under {@link module:maquette.dom}.
+   * @interface MaquetteDom
+   */
+
+  /**
+   * The main object in maquette is the maquette object.
+   * It is either bound to `window.maquette` or it can be obtained using {@link http://browserify.org/|browserify} or {@link http://requirejs.org/|requirejs}.
+   * @exports maquette
+   */
+  var maquette = {
+
+    /**
+     * The `h` method is used to create a virtual DOM node.
+     * This function is largely inspired by the mercuryjs and mithril frameworks.
+     * The `h` stands for (virtual) hyperscript.
+     *
+     * @param {string} selector - Contains the tagName, id and fixed css classnames in CSS selector format.
+     * It is formatted as follows: `tagname.cssclass1.cssclass2#id`.
+     * @param {Object} [properties] - An object literal containing properties that will be placed on the DOM node.
+     * @param {function} properties.<b>*</b> - Properties with functions values like `onclick:handleClick` are registered as event handlers
+     * @param {String} properties.<b>*</b> - Properties with string values, like `href:"/"` are used as attributes
+     * @param {object} properties.<b>*</b> - All non-string values are put on the DOM node as properties
+     * @param {Object} properties.key - Used to uniquely identify a DOM node among siblings.
+     * A key is required when there are more children with the same selector and these children are added or removed dynamically.
+     * @param {Object} properties.classes - An object literal like `{important:true}` which allows css classes, like `important` to be added and removed dynamically.
+     * @param {Object} properties.styles - An object literal like `{height:"100px"}` which allows styles to be changed dynamically. All values must be strings.
+     * @param {(string|enterAnimationCallback)} properties.enterAnimation - The animation to perform when this node is added to an already existing parent.
+     * {@link http://maquettejs.org/docs/animations.html|More about animations}.
+     * When this value is a string, you must pass a `projectionOptions.transitions` object when creating the projector {@link module:maquette.createProjector}.
+     * @param {(string|exitAnimationCallback)} properties.exitAnimation - The animation to perform when this node is removed while its parent remains.
+     * When this value is a string, you must pass a `projectionOptions.transitions` object when creating the projector {@link module:maquette.createProjector}.
+     * {@link http://maquettejs.org/docs/animations.html|More about animations}.
+     * @param {updateAnimationCallback} properties.updateAnimation - The animation to perform when the properties of this node change.
+     * This also includes attributes, styles, css classes. This callback is also invoked when node contains only text and that text changes.
+     * {@link http://maquettejs.org/docs/animations.html|More about animations}.
+     * @param {afterCreateCallback} properties.afterCreate - Callback that is executed after this node is added to the DOM. Childnodes and properties have already been applied.
+     * @param {afterUpdateCallback} properties.afterUpdate - Callback that is executed every time this node may have been updated. Childnodes and properties have already been updated.
+     * @param {Object[]} [children] - An array of virtual DOM nodes to add as child nodes.
+     * This array may contain nested arrays, `null` or `undefined` values.
+     * Nested arrays are flattened, `null` and `undefined` will be skipped.
+     *
+     * @returns {VNode} A VNode object, used to render a real DOM later. NOTE: There are {@link http://maquettejs.org/docs/rules.html|three basic rules} you should be aware of when updating the virtual DOM.
+     */
+    h: function (selector, properties, childrenArgs) {
+      if (typeof selector !== "string") {
+        throw new Error();
+      }
+      var childIndex = 1;
+      if (properties && !properties.hasOwnProperty("vnodeSelector") && !Array.isArray(properties) && typeof properties === "object") {
+        childIndex = 2;
+      } else {
+        // Optional properties argument was omitted
+        properties = undefined;
+      }
+      var text = undefined;
+      var children = undefined;
+      var argsLength = arguments.length;
+      // Recognize a common special case where there is only a single text node
+      if(argsLength === childIndex + 1) {
+        var onlyChild = arguments[childIndex];
+        if (typeof onlyChild === "string") {
+          text = onlyChild;
+        } else if (onlyChild !== undefined && onlyChild.length === 1 && typeof onlyChild[0] === "string") {
+          text = onlyChild[0];
+        }
+      }
+      if (text === undefined) {
+        children = [];
+        for (;childIndex<arguments.length;childIndex++) {
+          var child = arguments[childIndex];
+          if(child === null || child === undefined) {
+            continue;
+          } else if(Array.isArray(child)) {
+            appendChildren(selector, child, children);
+          } else if(child.hasOwnProperty("vnodeSelector")) {
+            children.push(child);
+          } else {
+            children.push(toTextVNode(child));
+          }
+        }
+      }
+      return {
+        /**
+         * The CSS selector containing tagname, css classnames and id. An empty string is used to denote a text node.
+         * @memberof VNode#
+         */
+        vnodeSelector: selector,
+        /**
+         * Object containing attributes, properties, event handlers and more @see module:maquette.h
+         * @memberof VNode#
+         */
+        properties: properties,
+        /**
+         * Array of VNodes to be used as children. This array is already flattened.
+         * @memberof VNode#
+         */
+        children: children,
+        /**
+         * Used in a special case when a VNode only has one childnode which is a textnode. Only used in combination with children === undefined.
+         * @memberof VNode#
+         */
+        text: text,
+        /**
+         * Used by maquette to store the domNode that was produced from this {@link VNode}.
+         * @memberof VNode#
+         */
+        domNode: null
+      };
+    },
+
+    /**
+     * @type MaquetteDom
+     */
+    dom: {
+      /**
+       * Creates a real DOM tree from a {@link VNode}. The {@link Projection} object returned will contain the resulting DOM Node under the {@link Projection#domNode} property.
+       * This is a low-level method. Users wil typically use a {@link Projector} instead.
+       * @memberof MaquetteDom#
+       * @param {VNode} vnode - The root of the virtual DOM tree that was created using the {@link module:maquette.h} function. NOTE: {@link VNode} objects may only be rendered once.
+       * @param {Object} projectionOptions - Options to be used to create and update the projection, see {@link module:maquette.createProjector}.
+       * @returns {Projection} The {@link Projection} which contains the DOM Node that was created.
+       */
+      create: function (vnode, projectionOptions) {
+        projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+        createDom(vnode, document.createElement("div"), undefined, projectionOptions);
+        return createProjection(vnode, projectionOptions);
+      },
+
+      /**
+       * Appends a new childnode to the DOM which is generated from a {@link VNode}.
+       * This is a low-level method. Users wil typically use a {@link Projector} instead.
+       * @memberof MaquetteDom#
+       * @param {Element} parentNode - The parent node for the new childNode.
+       * @param {VNode} vnode - The root of the virtual DOM tree that was created using the {@link module:maquette.h} function. NOTE: {@link VNode} objects may only be rendered once.
+       * @param {Object} projectionOptions - Options to be used to create and update the projection, see {@link module:maquette.createProjector}.
+       * @returns {Projection} The {@link Projection} that was created.
+       */
+      append: function (parentNode, vnode, projectionOptions) {
+        projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+        createDom(vnode, parentNode, undefined, projectionOptions);
+        return createProjection(vnode, projectionOptions);
+      },
+
+      /**
+       * Inserts a new DOM node which is generated from a {@link VNode}.
+       * This is a low-level method. Users wil typically use a {@link Projector} instead.
+       * @memberof MaquetteDom#
+       * @param {Element} beforeNode - The node that the DOM Node is inserted before.
+       * @param {VNode} vnode - The root of the virtual DOM tree that was created using the {@link module:maquette.h} function. NOTE: {@link VNode} objects may only be rendered once.
+       * @param {Object} projectionOptions - Options to be used to create and update the projection, see {@link module:maquette.createProjector}.
+       * @returns {Projection} The {@link Projection} that was created.
+       */
+      insertBefore: function(beforeNode, vnode, projectionOptions) {
+        projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+        createDom(vnode, beforeNode.parentNode, beforeNode, projectionOptions);
+        return createProjection(vnode, projectionOptions);
+      },
+
+      /**
+       * Merges a new DOM node which is generated from a {@link VNode} with an existing DOM Node.
+       * This means that the virtual DOM and real DOM have one overlapping element.
+       * Therefore the selector for the root {@link VNode} will be ignored, but its properties and children will be applied to the Element provided
+       * This is a low-level method. Users wil typically use a {@link Projector} instead.
+       * @memberof MaquetteDom#
+       * @param {Element} domNode - The existing element to adopt as the root of the new virtual DOM. Existing attributes and childnodes are preserved.
+       * @param {VNode} vnode - The root of the virtual DOM tree that was created using the {@link module:maquette.h} function. NOTE: {@link VNode} objects may only be rendered once.
+       * @param {Object} projectionOptions - Options to be used to create and update the projection, see {@link module:maquette.createProjector}.
+       * @returns {Projection} The {@link Projection} that was created.
+       */
+      merge: function (element, vnode, options) {
+        options = applyDefaultProjectionOptions(options);
+        vnode.domNode = element;
+        initPropertiesAndChildren(element, vnode, options);
+        return createProjection(vnode, options);
+      }
+    },
+
+    /**
+     * Creates a {@link CalculationCache} object, useful for caching {@link VNode} trees.
+     * In practice, caching of {@link VNode} trees is not needed, because achieving 60 frames per second is almost never a problem.
+     * @returns {CalculationCache}
+     */
+    createCache: function () {
+      var cachedInputs = undefined;
+      var cachedOutcome = undefined;
+      var result = {
+        /**
+         * Manually invalidates the cached outcome.
+         * @memberof CalculationCache#
+         */
+        invalidate: function () {
+          cachedOutcome = undefined;
+          cachedInputs = undefined;
+        },
+        /**
+         * If the inputs array matches the inputs array from the previous invocation, this method returns the result of the previous invocation.
+         * Otherwise, the calculation function is invoked and its result is cached and returned.
+         * Objects in the inputs array are compared using ===.
+         * @param {Object[]} inputs - Array of objects that are to be compared using === with the inputs from the previous invocation.
+         * These objects are assumed to be immutable primitive values.
+         * @param {function} calculation - Function that takes zero arguments and returns an object (A {@link VNode} assumably) that can be cached.
+         * @memberof CalculationCache#
+         */
+        result: function (inputs, calculation) {
+          if(cachedInputs) {
+            for(var i = 0; i < inputs.length; i++) {
+              if(cachedInputs[i] !== inputs[i]) {
+                cachedOutcome = undefined;
+              }
+            }
+          }
+          if(!cachedOutcome) {
+            cachedOutcome = calculation();
+            cachedInputs = inputs;
+          }
+          return cachedOutcome;
+        }
+      };
+      return result;
+    },
+
+    /**
+     * Creates a {@link Mapping} instance that keeps an array of result objects synchronized with an array of source objects.
+     * @param {function} getSourceKey - `function(source)` that must return a key to identify each source object. The result must eather be a string or a number.
+     * @param {function} createResult - `function(source, index)` that must create a new result object from a given source. This function is identical argument of `Array.map`.
+     * @param {function} updateResult - `function(source, target, index)` that updates a result to an updated source.
+     * @returns {Mapping}
+     */
+    createMapping: function(getSourceKey, createResult, updateResult /*, deleteTarget*/) {
+      var keys = [];
+      var results = [];
+
+      return {
+        /**
+         * The array of results. These results will be synchronized with the latest array of sources that were provided using {@link Mapping#map}.
+         * @type {Object[]}
+         * @memberof Mapping#
+         */
+        results: results,
+        /**
+         * Maps a new array of sources and updates {@link Mapping#results}.
+         * @param {Object[]} newSources - The new array of sources.
+         * @memberof Mapping#
+         */
+        map: function(newSources) {
+          var newKeys = newSources.map(getSourceKey);
+          var oldTargets = results.slice();
+          var oldIndex = 0;
+          for (var i=0;i<newSources.length;i++) {
+            var source = newSources[i];
+            var sourceKey = newKeys[i];
+            if (sourceKey === keys[oldIndex]) {
+              results[i] = oldTargets[oldIndex];
+              updateResult(source, oldTargets[oldIndex], i);
+              oldIndex++;
+            } else {
+              var found = false;
+              for (var j = 1; j < keys.length; j++) {
+                var searchIndex = (oldIndex + j) % keys.length;
+                if (keys[searchIndex] === sourceKey) {
+                  results[i] = oldTargets[searchIndex];
+                  updateResult(newSources[i], oldTargets[searchIndex], i);
+                  oldIndex = searchIndex + 1;
+                  found = true;
+                  break;
+                }
+              }
+              if (!found) {
+                results[i] = createResult(source, i);
+              }
+            }
+          }
+          results.length = newSources.length;
+          keys = newKeys;
+        }
+      };
+    },
+
+    /**
+     * Creates a {@link Projector} instance using the provided projectionOptions.
+     * @param {Object} [projectionOptions] - Options that influence how the DOM is rendered and updated.
+     * @param {Object} projectionOptions.transitions - A transition strategy to invoke when
+     * enterAnimation and exitAnimation properties are provided as strings.
+     * The module `cssTransitions` in the provided `css-transitions.js` file provides such a strategy.
+     * A transition strategy is not needed when enterAnimation and exitAnimation properties are provided as functions.
+     * @returns {Projector}
+     */
+    createProjector: function (projectionOptions) {
+      projectionOptions = applyDefaultProjectionOptions(projectionOptions);
+      projectionOptions.eventHandlerInterceptor = function (propertyName, functionPropertyArgument) {
+        return function () {
+          // intercept function calls (event handlers) to do a render afterwards.
+          projector.scheduleRender();
+          return functionPropertyArgument.apply(this, arguments);
+        };
+      };
+      var renderCompleted = true;
+      var scheduled;
+      var stopped = false;
+      var projections = [];
+      var renderFunctions = []; // matches the projections array
+
+      var doRender = function () {
+        scheduled = undefined;
+        if (!renderCompleted) {
+          return; // The last render threw an error, it should be logged in the browser console.
+        }
+        var s = Date.now()
+        renderCompleted = false;
+        for(var i = 0; i < projections.length; i++) {
+          var updatedVnode = renderFunctions[i]();
+          projections[i].update(updatedVnode);
+        }
+        renderCompleted = true;
+        if (Date.now()-s > 5)
+          console.log("Render:", Date.now()-s)
+      };
+
+      var projector = {
+        /**
+         * Instructs the projector to re-render to the DOM at the next animation-frame using the registered `renderMaquette` functions.
+         * This method is automatically called for you when event-handlers that are registered in the {@link VNode}s are invoked.
+         * You need to call this method for instance when timeouts expire or AJAX responses arrive.
+         * @memberof Projector#
+         */
+        scheduleRender: function () {
+          if(!scheduled && !stopped) {
+            scheduled = requestAnimationFrame(doRender);
+          }
+        },
+        /**
+         * Stops the projector. This means that the registered `renderMaquette` functions will not be called anymore.
+         * Note that calling {@link Projector#stop} is not mandatory. A projector is a passive object that will get garbage collected as usual if it is no longer in scope.
+         * @memberof Projector#
+         */
+        stop: function () {
+          if(scheduled) {
+            cancelAnimationFrame(scheduled);
+            scheduled = undefined;
+          }
+          stopped = true;
+        },
+        /**
+         * Resumes the projector. Use this method to resume rendering after stop was called or an error occurred during rendering.
+         * @memberof Projector#
+         */
+        resume: function() {
+          stopped = false;
+          renderCompleted = true;
+          projector.scheduleRender();
+        },
+        /**
+         * Scans the document for `<script>` tags with `type="text/hyperscript"`.
+         * The content of these scripts are registered as `renderMaquette` functions.
+         * The result of evaluating these functions will be inserted into the DOM after the script.
+         * These scripts can make use of variables that come from the `parameters` parameter.
+         * @param {Element} rootNode - Element to start scanning at, example: `document.body`.
+         * @param {Object} parameters - Variables to expose to the scripts. format: `{var1:value1, var2: value2}`
+         * @memberof Projector#
+         */
+        evaluateHyperscript: function (rootNode, parameters) {
+          var nodes = rootNode.querySelectorAll("script[type='text/hyperscript']");
+          var functionParameters = ["maquette", "h", "enhancer"];
+          var parameterValues = [maquette, maquette.h, projector];
+          Object.keys(parameters).forEach(function (parameterName) {
+            functionParameters.push(parameterName);
+            parameterValues.push(parameters[parameterName]);
+          });
+          Array.prototype.forEach.call(nodes, function (node) {
+            var func = new Function(functionParameters, "return " + node.textContent.trim());
+            var renderFunction = function () {
+              return func.apply(undefined, parameterValues);
+            };
+            projector.insertBefore(node, renderFunction);
+          });
+        },
+        /**
+         * Appends a new childnode to the DOM using the result from the provided `renderMaquetteFunction`.
+         * The `renderMaquetteFunction` will be invoked again to update the DOM when needed.
+         * @param {Element} parentNode - The parent node for the new childNode.
+         * @param {function} renderMaquetteFunction - Function with zero arguments that returns a {@link VNode} tree.
+         * @memberof Projector#
+         */
+        append: function (parentNode, renderMaquetteFunction) {
+          projections.push(maquette.dom.append(parentNode, renderMaquetteFunction(), projectionOptions));
+          renderFunctions.push(renderMaquetteFunction);
+        },
+        /**
+         * Inserts a new DOM node using the result from the provided `renderMaquetteFunction`.
+         * The `renderMaquetteFunction` will be invoked again to update the DOM when needed.
+         * @param {Element} beforeNode - The node that the DOM Node is inserted before.
+         * @param {function} renderMaquetteFunction - Function with zero arguments that returns a {@link VNode} tree.
+         * @memberof Projector#
+         */
+        insertBefore: function (beforeNode, renderMaquetteFunction) {
+          projections.push(maquette.dom.insertBefore(beforeNode, renderMaquetteFunction(), projectionOptions));
+          renderFunctions.push(renderMaquetteFunction);
+        },
+        /**
+         * Merges a new DOM node using the result from the provided `renderMaquetteFunction` with an existing DOM Node.
+         * This means that the virtual DOM and real DOM have one overlapping element.
+         * Therefore the selector for the root {@link VNode} will be ignored, but its properties and children will be applied to the Element provided
+         * The `renderMaquetteFunction` will be invoked again to update the DOM when needed.
+         * @param {Element} domNode - The existing element to adopt as the root of the new virtual DOM. Existing attributes and childnodes are preserved.
+         * @param {function} renderMaquetteFunction - Function with zero arguments that returns a {@link VNode} tree.
+         * @memberof Projector#
+         */
+        merge: function (domNode, renderMaquetteFunction) {
+          projections.push(maquette.dom.merge(domNode, renderMaquetteFunction(), projectionOptions));
+          renderFunctions.push(renderMaquetteFunction);
+        },
+        /**
+         * Replaces an existing DOM node with the result from the provided `renderMaquetteFunction`.
+         * The `renderMaquetteFunction` will be invoked again to update the DOM when needed.
+         * @param {Element} domNode - The DOM node to replace.
+         * @param {function} renderMaquetteFunction - Function with zero arguments that returns a {@link VNode} tree.
+         * @memberof Projector#
+         */
+        replace: function (domNode, renderMaquetteFunction) {
+          var vnode = renderMaquetteFunction();
+          createDom(vnode, domNode.parentNode, domNode, projectionOptions);
+          domNode.parentNode.removeChild(domNode);
+          projections.push(createProjection(vnode, projectionOptions));
+          renderFunctions.push(renderMaquetteFunction);
+        }
+      };
+      return projector;
+    }
+  };
+
+  if(typeof module !== "undefined" && module.exports) {
+    // Node and other CommonJS-like environments that support module.exports
+    module.exports = maquette;
+  } else if(typeof define === "function" && define.amd) {
+    // AMD / RequireJS
+    define(function () {
+      return maquette;
+    });
+  } else {
+    // Browser
+    window.maquette = maquette;
+  }
+
+})(this);

File diff suppressed because it is too large
+ 5 - 0
js/lib/marked.min.js


+ 185 - 0
js/utils/Animation.coffee

@@ -0,0 +1,185 @@
+class Animation
+	slideDown: (elem, props) ->
+		h = elem.offsetHeight
+		cstyle = window.getComputedStyle(elem)
+		margin_top = cstyle.marginTop
+		margin_bottom = cstyle.marginBottom
+		padding_top = cstyle.paddingTop
+		padding_bottom = cstyle.paddingBottom
+		transition = cstyle.transition
+
+		elem.style.boxSizing = "border-box"
+		elem.style.overflow = "hidden"
+		elem.style.transform = "scale(0.8)"
+		elem.style.opacity = "0"
+		elem.style.height = "0px"
+		elem.style.marginTop = "0px"
+		elem.style.marginBottom = "0px"
+		elem.style.paddingTop = "0px"
+		elem.style.paddingBottom = "0px"
+		elem.style.transition = "none"
+
+		setTimeout (->
+			elem.className += " animate-back"
+			elem.style.height = h+"px"
+			elem.style.transform = "scale(1)"
+			elem.style.opacity = "1"
+			elem.style.marginTop = margin_top
+			elem.style.marginBottom = margin_bottom
+			elem.style.paddingTop = padding_top
+			elem.style.paddingBottom = padding_bottom
+		), 1
+
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate-back")
+			elem.style.transition = elem.style.transform = elem.style.opacity = elem.style.height = null
+			elem.style.boxSizing = elem.style.marginTop = elem.style.marginBottom = null
+			elem.style.paddingTop = elem.style.paddingBottom = elem.style.overflow = null
+
+
+	slideUp: (elem, remove_func, props) ->
+		elem.className += " animate-back"
+		elem.style.boxSizing = "border-box"
+		elem.style.height = elem.offsetHeight+"px"
+		elem.style.overflow = "hidden"
+		elem.style.transform = "scale(1)"
+		elem.style.opacity = "1"
+		setTimeout (->
+			elem.style.height = "0px"
+			elem.style.marginTop = "0px"
+			elem.style.marginBottom = "0px"
+			elem.style.paddingTop = "0px"
+			elem.style.paddingBottom = "0px"
+			elem.style.transform = "scale(0.8)"
+			elem.style.borderTopWidth = "0px"
+			elem.style.borderBottomWidth = "0px"
+			elem.style.opacity = "0"
+		), 1
+		elem.addEventListener("transitionend", remove_func)
+
+
+	showRight: (elem, props) ->
+		elem.className += " animate"
+		elem.style.opacity = 0
+		elem.style.transform = "TranslateX(-20px) Scale(1.01)"
+		setTimeout (->
+			elem.style.opacity = 1
+			elem.style.transform = "TranslateX(0px) Scale(1)"
+		), 1
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate")
+			elem.style.transform = elem.style.opacity = null
+
+
+	show: (elem, props) ->
+		delay = arguments[arguments.length-2]?.delay*1000 or 1
+		elem.className += " animate"
+		elem.style.opacity = 0
+		setTimeout (->
+			elem.style.opacity = 1
+		), delay
+		elem.addEventListener "transitionend", ->
+			elem.classList.remove("animate")
+			elem.style.opacity = null
+
+
+	addVisibleClass: (elem, props) ->
+		setTimeout ->
+			elem.classList.add("visible")
+
+
+	termLines: (elem, projection_options, selector, props) ->
+		lines = elem.innerHTML.split("\n")
+		delay = props.delay or 0
+		delay_step = props.delay_step or 0.05
+		back = []
+		for line in lines
+			line = line.replace(/(\.+)(.*?)$/, "<span class='dots'>$1</span><span class='result'>$2</span>", line)
+			back.push("<span style='transition-delay: #{delay}s'>#{line}</span>")
+			delay += delay_step
+		setTimeout ( ->
+			elem.classList.add("visible")
+		), 100
+		elem.innerHTML = back.join("\n")
+
+	scramble: (elem) ->
+		text_original = elem.value
+		chars = elem.value.split("")
+		chars = chars.filter (char) ->
+			return char != "\n" and char != "\r" and char != " " and char != "​"
+
+		#replaces = ["|", "[", "]", "/", "\\", "*", "-", "$", "~", "^", "#", ">", "<", "(", ")", "+", "%", "=", "!"]
+		replaces = ["⠋", '⠙', '⠹', '⠒', '⠔', '⠃', '⡳', '⠁', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+		replaces.sort ->
+			return 0.5-Math.random()
+
+		frame = 0
+		timer = setInterval ( ->
+			for i in [0..Math.round(text_original.length/20)]
+				char = chars.shift()
+				elem.value = elem.value.replace(char, replaces[(frame+i) % replaces.length])
+
+			if chars.length == 0
+				clearInterval(timer)
+
+			frame += 1
+		), 50
+
+	###
+	showScramble: (elem, props) ->
+		text_original = elem.innerText
+
+		chars = elem.innerText.split("")
+
+		# Convert characters to whitespace
+		clear_chars = chars.map (char) ->
+			if char != "\n" and char != "\r" and char != " " and char != "​"
+				return " "
+			else
+				return char
+		elem.innerText = clear_chars.join("")
+
+		replaces = ["|", "[", "]", "/", "\\", "*", "-", "$", "~", "^", "#", ">", "<", "(", ")", "+", "%", "=", "!"]
+		replaces.sort ->
+			return 0.5-Math.random()
+
+		frame = 0
+		timer = 0
+		replace_show = ->
+			for i in [0..10]
+				replace = replaces[Math.floor(Math.random()*(replaces.length-1))]
+				elem.innerText = elem.innerText.replace(" ", replace)
+				elem.innerText = elem.innerText.replace(replace, replaces[frame % (replaces.length-1)])
+			frame += 1
+			if frame > chars.length/10
+				clearInterval(timer)
+				timer = setInterval text_show, 20
+
+		text_show = ->
+			for i in [0..10]
+
+
+			clearInterval(timer)
+
+		timer = setInterval replace_show, 20
+
+
+	scramble2: (elem, props) ->
+		text_original = elem.innerText
+		chars = elem.innerText.split("")
+		chars_num = chars.length
+		frame = 0
+		timer = setInterval ( ->
+			for replace in ["|", "[", "]", "/", "\\", "*", "-", "$", "~", "^", "#", ">", "<", "(", ")", "+", "%", "=", "!"]
+				index = Math.round(Math.random()*chars_num)
+				if chars[index] != "\n" and chars[index] != "\r" and chars[index] != " " and chars[index] != "​" # Null character
+					chars[index] = replace
+			elem.innerText = chars.join("")
+			frame += 1
+			if frame > 100
+				clearInterval(timer)
+		), 20
+		@
+	###
+
+window.Animation = new Animation()

+ 116 - 0
js/utils/Autocomplete.coffee

@@ -0,0 +1,116 @@
+class Autocomplete
+	constructor: (@getValues, @attrs={}, @onChanged=null) ->
+		@attrs.oninput = @handleInput
+		@attrs.onfocus = @handleFocus
+		@attrs.onblur = @handleBlur
+		@attrs.onkeydown = @handleKey
+
+		@values = []
+		@selected_index = 0
+		@focus = false
+
+
+	setNode: (node) =>
+		@node = node
+
+	setValue: (value) ->
+		@attrs.value = value
+		if @onChanged
+			@onChanged(value)
+		Page.projector.scheduleRender()
+
+
+	filterValues: (filter) =>
+		current_value = @attrs.value
+		values = @getValues()
+		re_highlight = new RegExp("^(.*?)("+filter.split("").join(")(.*?)(")+")(.*?)$", "i")
+		res = []
+		for value in values
+			distance = Text.distance(value, current_value)
+			if distance != false
+				# Highlight matched part (every second group)
+				match = value.match(re_highlight)
+				if not match then continue
+				parts = match.map (part, i) ->
+					if i % 2 == 0
+						return "<b>#{part}</b>"
+					else
+						return part
+
+				parts.shift()  # First part is full string
+				res.push([parts.join(""), distance])
+
+		res.sort (a,b) ->
+			return a[1] - b[1]
+
+		@values = (row[0] for row in res[0..9])
+		return @values
+
+
+	renderValue: (node, projector_options, children, attrs) ->
+		node.innerHTML = attrs.key
+
+
+	handleInput: (e) =>
+		@attrs.value = e.target.value
+		@selected_index = 0
+		@focus = true
+
+
+	handleKey: (e) =>
+		if e.keyCode == 38  # Up
+			@selected_index = Math.max(0, @selected_index-1)
+			return false
+		else if e.keyCode == 40  # Down
+			@selected_index = Math.min(@values.length-1, @selected_index+1)
+			return false
+		else if e.keyCode == 13  # Enter
+			@handleBlur(e)
+			return false
+
+
+	handleClick: (e) =>
+		e.currentTarget ?= e.explicitOriginalTarget
+		@attrs.value = e.currentTarget.textContent
+		if @onChanged
+			@onChanged(@attrs.value)
+
+		@focus = false
+		Page.projector.scheduleRender()
+
+		return false
+
+
+	handleFocus: (e) =>
+		@selected_index = 0
+		@focus = true
+
+
+	handleBlur: (e) =>
+		selected_value = @node.querySelector(".values .value.selected")
+		if selected_value
+			@setValue selected_value.textContent
+		else if @attrs.value
+			values = @filterValues(@attrs.value)
+			if values.length > 0
+				@setValue values[0].replace(/<.*?>/g, "")
+			else
+				@setValue ""
+		else
+			@setValue ""
+		@focus = false
+
+
+	render: ->
+		h("div.Autocomplete", {"afterCreate": @setNode}, [
+			h("input.to", @attrs)
+			if @focus and @attrs.value then h("div.values", {"exitAnimation": Animation.slideUp}, [
+				@filterValues(@attrs.value).map (value, i) =>
+					h("a.value", {
+						"href": "#Select+Address", "key": value, "tabindex": "-1", "afterCreate": @renderValue, "onmousedown": @handleClick,
+						"classes": {"selected": @selected_index == i}
+					})
+			])
+		])
+
+window.Autocomplete = Autocomplete

+ 23 - 0
js/utils/Class.coffee

@@ -0,0 +1,23 @@
+class Class
+	trace: true
+
+	log: (args...) ->
+		return unless @trace
+		return if typeof console is 'undefined'
+		args.unshift("[#{@.constructor.name}]")
+		console.log(args...)
+		@
+		
+	logStart: (name, args...) ->
+		return unless @trace
+		@logtimers or= {}
+		@logtimers[name] = +(new Date)
+		@log "#{name}", args..., "(started)" if args.length > 0
+		@
+		
+	logEnd: (name, args...) ->
+		ms = +(new Date)-@logtimers[name]
+		@log "#{name}", args..., "(Done in #{ms}ms)"
+		@ 
+
+window.Class = Class

+ 3 - 0
js/utils/Dollar.coffee

@@ -0,0 +1,3 @@
+window.$ = (selector) ->
+	if selector.startsWith("#")
+		return document.getElementById(selector.replace("#", ""))

+ 84 - 0
js/utils/OrderManager.coffee

@@ -0,0 +1,84 @@
+# based on https://github.com/johan-gorter/maquette-demo-hero
+
+class OrderManager extends Class
+	constructor: ->
+		timer_reorder = null
+		@entering = {}
+		@exiting = {}
+		@
+
+
+	registerEnter: (elem, props, cb) ->
+		@entering[props.key] = [elem, cb]
+
+
+	registerExit: (elem, props, cb) ->
+		@exiting[props.key] = [elem, cb]
+
+
+	animateChange: (elem, before, after) ->
+		height = elem.offsetHeight
+		elem.style.width = elem.offsetWidth+"px"
+		elem.style.transition = "none"
+		elem.style.boxSizing = "border-box"
+		elem.style.float = "left"
+		elem.style.marginTop = 0-height+"px"
+		elem.style.transform = "TranslateY(#{before-after+height}px)"
+		setTimeout (->
+			elem.style.transition = null
+			elem.className += " animate-back"
+		), 1
+		setTimeout (->
+			elem.style.transform = "TranslateY(0px)"
+			elem.style.marginTop = "0px"
+			elem.addEventListener "transitionend", ->
+				elem.classList.remove("animate-back")
+				elem.style.boxSizing = elem.style.float = elem.style.marginTop = elem.style.transform = elem.style.width = null
+		), 2
+
+
+	execute: (elem, projection_options, selector, properties, childs) =>
+		s = Date.now()
+		has_entering = JSON.stringify(@entering) != "{}"
+		has_exiting = JSON.stringify(@exiting) != "{}"
+		if not has_exiting and not has_entering
+			return false
+
+		moving = {}
+
+		@log Date.now() - s
+		if childs.length < 5000  # Do not animate with too much childs
+			if has_entering and has_exiting
+				for child in childs
+					key = child.properties.key
+					if not key
+						continue
+					if not @entering[key]
+						moving[key] = [child, child.domNode.offsetTop]
+
+		@log Date.now() - s
+		for key, [child_elem, exitanim] of @exiting
+			if not @entering[key]
+				exitanim()
+			else
+				elem.removeChild(child_elem)
+		@log Date.now() - s
+
+		for key, [child_elem, enteranim] of @entering
+			if not @exiting[key]
+				enteranim()
+		@log Date.now() - s
+
+
+		for key, [child, top_before] of moving
+			top_after = child.domNode.offsetTop
+			console.log("animateChange", top_before, top_after)
+			if top_before != top_after
+				@animateChange(child.domNode, top_before, top_after)
+
+		@entering = {}
+		@exiting = {}
+
+		@log Date.now() - s, arguments
+
+window.OrderManager = OrderManager

+ 8 - 0
js/utils/Prototypes.coffee

@@ -0,0 +1,8 @@
+String::startsWith ?= (s) -> @[...s.length] is s
+String::endsWith   ?= (s) -> s is '' or @[-s.length..] is s
+String::repeat ?= (count) -> new Array( count + 1 ).join(@)
+
+window.isEmpty = (obj) ->
+	for key of obj
+		return false
+	return true

+ 14 - 0
js/utils/RateLimit.coffee

@@ -0,0 +1,14 @@
+limits = {}
+call_after_interval = {}
+window.RateLimit = (interval, fn) ->
+	if not limits[fn]
+		call_after_interval[fn] = false
+		fn() # First call is not delayed
+		limits[fn] = setTimeout (->
+			if call_after_interval[fn]
+				fn()
+			delete limits[fn]
+			delete call_after_interval[fn]
+		), interval
+	else # Called within iterval, delay the call
+		call_after_interval[fn] = true

+ 119 - 0
js/utils/Text.coffee

@@ -0,0 +1,119 @@
+class MarkedRenderer extends marked.Renderer
+	image: (href, title, text) ->
+		return ("<code>![#{text}](#{href})</code>")
+
+class Text
+	toColor: (text) ->
+		hash = 0
+		for i in [0..text.length-1]
+			hash += text.charCodeAt(i)*i
+			hash = hash % 1000
+		return "hsl(" + (hash % 360) + ",30%,50%)";
+
+
+	renderMarked: (text, options={}) ->
+		options["gfm"] = true
+		options["breaks"] = true
+		options["renderer"] = marked_renderer
+		text = @fixReply(text)
+		text = marked(text, options)
+		text = @emailLinks text
+		return @fixHtmlLinks text
+
+	emailLinks: (text) ->
+		return text.replace(/([a-zA-Z0-9]+)@zeroid.bit/, "<a href='?to=$1' onclick='return Page.message_create.show(\"$1\")'>$1@zeroid.bit</a>")
+
+	# Convert zeronet html links to relaitve
+	fixHtmlLinks: (text) ->
+		if window.is_proxy
+			return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="http://zero')
+		else
+			return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="')
+
+	# Convert a single link to relative
+	fixLink: (link) ->
+		if window.is_proxy
+			return link.replace(/http:\/\/(127.0.0.1|localhost):43110/, 'http://zero')
+		else
+			return link.replace(/http:\/\/(127.0.0.1|localhost):43110/, '')
+
+	toUrl: (text) ->
+		return text.replace(/[^A-Za-z0-9]/g, "+").replace(/[+]+/g, "+").replace(/[+]+$/, "")
+
+	fixReply: (text) ->
+		return text.replace(/(>.*\n)([^\n>])/gm, "$1\n$2")
+
+	toBitcoinAddress: (text) ->
+		return text.replace(/[^A-Za-z0-9]/g, "")
+
+
+	jsonEncode: (obj) ->
+		return unescape(encodeURIComponent(JSON.stringify(obj)))
+
+	jsonDecode: (obj) ->
+		return JSON.parse(decodeURIComponent(escape(obj)))
+
+	fileEncode: (obj) ->
+		if typeof(obj) == "string"
+			return btoa(unescape(encodeURIComponent(obj)))
+		else
+			return btoa(unescape(encodeURIComponent(JSON.stringify(obj, undefined, '\t'))))
+
+	utf8Encode: (s) ->
+		return unescape(encodeURIComponent(s))
+
+	utf8Decode: (s) ->
+		return decodeURIComponent(escape(s))
+
+
+	distance: (s1, s2) ->
+		s1 = s1.toLocaleLowerCase()
+		s2 = s2.toLocaleLowerCase()
+		next_find_i = 0
+		next_find = s2[0]
+		match = true
+		extra_parts = {}
+		for char in s1
+			if char != next_find
+				if extra_parts[next_find_i]
+					extra_parts[next_find_i] += char
+				else
+					extra_parts[next_find_i] = char
+			else
+				next_find_i++
+				next_find = s2[next_find_i]
+
+		if extra_parts[next_find_i]
+			extra_parts[next_find_i] = ""  # Extra chars on the end doesnt matter
+		extra_parts = (val for key, val of extra_parts)
+		if next_find_i >= s2.length
+			return extra_parts.length + extra_parts.join("").length
+		else
+			return false
+
+
+	parseQuery: (query) ->
+		params = {}
+		parts = query.split('&')
+		for part in parts
+			[key, val] = part.split("=")
+			if val
+				params[decodeURIComponent(key)] = decodeURIComponent(val)
+			else
+				params["url"] = decodeURIComponent(key)
+		return params
+
+	encodeQuery: (params) ->
+		back = []
+		if params.url
+			back.push(params.url)
+		for key, val of params
+			if not val or key == "url"
+				continue
+			back.push("#{encodeURIComponent(key)}=#{encodeURIComponent(val)}")
+		return back.join("&")
+
+
+window.is_proxy = (window.location.pathname == "/")
+window.marked_renderer = new MarkedRenderer()
+window.Text = new Text()

+ 39 - 0
js/utils/Time.coffee

@@ -0,0 +1,39 @@
+class Time
+	since: (timestamp) ->
+		now = +(new Date)/1000
+		if timestamp > 1000000000000  # In ms
+			timestamp = timestamp/1000
+		secs = now - timestamp
+		if secs < 60
+			back = "Just now"
+		else if secs < 60*60
+			back = "#{Math.round(secs/60)} minutes ago"
+		else if secs < 60*60*24
+			back = "#{Math.round(secs/60/60)} hours ago"
+		else if secs < 60*60*24*3
+			back = "#{Math.round(secs/60/60/24)} days ago"
+		else
+			back = "on "+@date(timestamp)
+		back = back.replace(/1 ([a-z]+)s/, "1 $1") # 1 days ago fix
+		return back
+
+
+	date: (timestamp, format="short") ->
+		if timestamp > 1000000000000  # In ms
+			timestamp = timestamp/1000
+		parts = (new Date(timestamp*1000)).toString().split(" ")
+		if format == "short"
+			display = parts.slice(1, 4)
+		else
+			display = parts.slice(1, 5)
+		return display.join(" ").replace(/( [0-9]{4})/, ",$1")
+
+
+	timestamp: (date="") ->
+		if date == "now" or date == ""
+			return parseInt(+(new Date)/1000)
+		else
+			return parseInt(Date.parse(date)/1000)
+
+
+window.Time = new Time

+ 69 - 0
js/utils/ZeroFrame.coffee

@@ -0,0 +1,69 @@
+class ZeroFrame extends Class
+	constructor: (url) ->
+		@url = url
+		@waiting_cb = {}
+		@connect()
+		@next_message_id = 1
+		@init()
+
+
+	init: ->
+		@
+
+
+	connect: ->
+		@target = window.parent
+		window.addEventListener("message", @onMessage, false)
+		@cmd("innerReady")
+
+
+	onMessage: (e) =>
+		message = e.data
+		cmd = message.cmd
+		if cmd == "response"
+			if @waiting_cb[message.to]?
+				@waiting_cb[message.to](message.result)
+			else
+				@log "Websocket callback not found:", message
+		else if cmd == "wrapperReady" # Wrapper inited later
+			@cmd("innerReady")
+		else if cmd == "ping"
+			@response message.id, "pong"
+		else if cmd == "wrapperOpenedWebsocket"
+			@onOpenWebsocket()
+		else if cmd == "wrapperClosedWebsocket"
+			@onCloseWebsocket()
+		else
+			@onRequest cmd, message.params
+
+
+	onRequest: (cmd, message) =>
+		@log "Unknown request", message
+
+
+	response: (to, result) ->
+		@send {"cmd": "response", "to": to, "result": result}
+
+
+	cmd: (cmd, params={}, cb=null) ->
+		@send {"cmd": cmd, "params": params}, cb
+
+
+	send: (message, cb=null) ->
+		message.id = @next_message_id
+		@next_message_id += 1
+		@target.postMessage(message, "*")
+		if cb
+			@waiting_cb[message.id] = cb
+
+
+	onOpenWebsocket: =>
+		@log "Websocket open"
+
+
+	onCloseWebsocket: =>
+		@log "Websocket close"
+
+
+
+window.ZeroFrame = ZeroFrame

Some files were not shown because too many files changed in this diff