summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Vagrantfile1
-rw-r--r--osx_setup.sh37
-rw-r--r--service/pixelated/adapter/mailstore/leap_mailstore.py1
-rw-r--r--service/pixelated/adapter/mailstore/maintenance/__init__.py49
-rw-r--r--service/pixelated/adapter/search/__init__.py3
-rw-r--r--service/pixelated/resources/keys_resource.py2
-rw-r--r--service/pixelated/resources/logout_resource.py19
-rw-r--r--service/pixelated/resources/root_resource.py2
-rw-r--r--service/pixelated/resources/sandbox_resource.py34
-rw-r--r--service/requirements.txt16
-rw-r--r--service/test/functional/features/steps/attachments.py2
-rw-r--r--service/test/functional/features/steps/mail_view.py13
-rw-r--r--service/test/load/locustfile.py78
-rw-r--r--service/test/unit/adapter/mailstore/maintenance/test_soledad_maintenance.py38
-rw-r--r--service/test/unit/resources/test_keys_resources.py24
-rw-r--r--service/test/unit/resources/test_logout_resources.py15
-rw-r--r--service/test/unit/resources/test_sandbox_resource.py38
-rw-r--r--service/test_requirements.txt3
-rw-r--r--web-ui/app/index.html6
-rw-r--r--web-ui/app/js/helpers/sanitizer.js32
-rw-r--r--web-ui/app/js/mail_list/ui/mail_list.js1
-rw-r--r--web-ui/app/js/mail_view/ui/mail_view.js58
-rw-r--r--web-ui/app/js/mail_view/ui/reply_section.js20
-rw-r--r--web-ui/app/js/page/events.js5
-rw-r--r--web-ui/app/js/sandbox.js9
-rw-r--r--web-ui/app/js/search/results_highlighter.js29
-rw-r--r--web-ui/app/js/user_alerts/ui/user_alerts.js14
-rw-r--r--web-ui/app/sandbox.html17
-rw-r--r--web-ui/app/scss/_alerts.scss25
-rw-r--r--web-ui/app/scss/_compose.scss185
-rw-r--r--web-ui/app/scss/_mascot.scss47
-rw-r--r--web-ui/app/scss/_mixins.scss5
-rw-r--r--web-ui/app/scss/_others.scss7
-rw-r--r--web-ui/app/scss/_read.scss164
-rw-r--r--web-ui/app/scss/_reply.scss2
-rw-r--r--web-ui/app/scss/_security.scss3
-rw-r--r--web-ui/app/scss/_styles.scss32
-rw-r--r--web-ui/app/scss/base/_colors.scss (renamed from web-ui/app/scss/_colors.scss)0
-rw-r--r--web-ui/app/scss/base/_fonts.scss (renamed from web-ui/app/scss/opensans.scss)0
-rw-r--r--web-ui/app/scss/base/_scaffolding.scss10
-rw-r--r--web-ui/app/scss/main.scss37
-rw-r--r--web-ui/app/scss/mixins/_position-helpers.scss9
-rw-r--r--web-ui/app/scss/sandbox.scss20
-rw-r--r--web-ui/app/scss/style.scss35
-rw-r--r--web-ui/app/scss/templates/_no-content-placeholder.scss5
-rw-r--r--web-ui/app/scss/vendor/_foundation.scss (renamed from web-ui/app/scss/foundation.scss)0
-rw-r--r--web-ui/app/scss/vendor/_reset.scss (renamed from web-ui/app/scss/reset.scss)0
-rw-r--r--web-ui/app/scss/vendor/_scut.scss1518
-rw-r--r--web-ui/app/scss/views/_close-button.scss22
-rw-r--r--web-ui/app/scss/views/_compose-button.scss24
-rw-r--r--web-ui/app/scss/views/_compose-view.scss320
-rw-r--r--web-ui/app/scss/views/_message-panel.scss26
-rw-r--r--web-ui/app/scss/views/_no-mails-available.scss3
-rw-r--r--web-ui/app/scss/views/_no-message-selected.scss14
-rw-r--r--web-ui/app/scss/views/_read-view.scss181
-rw-r--r--web-ui/app/templates/compose/compose_box.hbs50
-rw-r--r--web-ui/app/templates/compose/no_mails_available.hbs6
-rw-r--r--web-ui/app/templates/compose/no_message_selected.hbs4
-rw-r--r--web-ui/app/templates/mails/full_view.hbs100
-rw-r--r--web-ui/app/templates/mails/mail_actions.hbs10
-rw-r--r--web-ui/app/templates/search/search_trigger.hbs2
-rw-r--r--web-ui/app/templates/user_alerts/message.hbs2
-rw-r--r--web-ui/bower.json3
-rw-r--r--web-ui/config/package.sh20
-rw-r--r--web-ui/package.json1
-rw-r--r--web-ui/test/spec/helpers/sanitizer.spec.js6
-rw-r--r--web-ui/test/spec/mail_view/ui/mail_view.spec.js13
-rw-r--r--web-ui/test/spec/mail_view/ui/no_mails_available_pane.spec.js6
-rw-r--r--web-ui/test/spec/mail_view/ui/reply_section.spec.js16
-rw-r--r--web-ui/test/spec/search/results_highlighter.spec.js14
-rw-r--r--web-ui/test/spec/user_alerts/ui/user_alerts.spec.js6
71 files changed, 2802 insertions, 717 deletions
diff --git a/Vagrantfile b/Vagrantfile
index ba925079..828382b9 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -30,5 +30,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider "virtualbox" do |v, override|
v.memory = 1024
override.vm.network :forwarded_port, guest: 3333, host: 3333 # do NOT add host_ip in this line. It is not necessary
+ override.vm.network :forwarded_port, guest: 8089, host: 8089
end
end
diff --git a/osx_setup.sh b/osx_setup.sh
index a8a37833..d042d045 100644
--- a/osx_setup.sh
+++ b/osx_setup.sh
@@ -1,5 +1,35 @@
#!/bin/bash
+# Test to make sure we are OSX
+if [ $(uname) != 'Darwin' ]
+then
+ echo "This script should run only on an OSX system!"
+ exit 1
+fi
+
+# Read the shell configured for the user and set the variable file accordingly
+function current_shell {
+ case $SHELL in
+ *bash)
+ echo ~/.bash_profile
+ ;;
+
+ *zsh)
+ echo ~/.zprofile
+ ;;
+
+ /bin/sh)
+ echo ~/.profile
+ ;;
+
+ *)
+ echo "Your shell isn't supported yet!"
+ ;;
+
+ #Other shells can go here
+ esac
+}
+
function install_compass {
rbenv install -s 2.2.3
eval "$(rbenv init -)"
@@ -7,8 +37,8 @@ function install_compass {
rbenv local 2.2.3
gem install compass
export PATH=$PATH:~/.rbenv/versions/2.2.3/bin
- echo "export PATH=$PATH:~/.rbenv/versions/2.2.3/bin" >> ~/.bash_profile
- echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
+ echo "export PATH=$PATH:~/.rbenv/versions/2.2.3/bin" >> $(current_shell)
+ echo 'eval "$(rbenv init -)"' >> $(current_shell)
}
function install_rbenv {
@@ -30,13 +60,14 @@ function clone_repo {
cd pixelated-user-agent
fi
}
+
#setup frontend
install_rbenv
install_compass
install_npm
#setup backend
-brew install python # force brew install even if python is already installed
+brew install python # force brew install even if python is already install
export LDFLAGS=-L/usr/local/opt/openssl/lib
export LDFLAGS=-L/usr/local/opt/openssl/lib
pip install virtualenv
diff --git a/service/pixelated/adapter/mailstore/leap_mailstore.py b/service/pixelated/adapter/mailstore/leap_mailstore.py
index 975bcc5c..cd4cb5b8 100644
--- a/service/pixelated/adapter/mailstore/leap_mailstore.py
+++ b/service/pixelated/adapter/mailstore/leap_mailstore.py
@@ -27,6 +27,7 @@ from pixelated.adapter.mailstore.mailstore import MailStore, underscore_uuid
from pixelated.adapter.model.mail import Mail, InputMail
from pixelated.support import log_time_deferred
from pixelated.support.functional import to_unicode
+from pixelated.support import date
MIME_PGP_KEY = 'application/pgp-keys'
diff --git a/service/pixelated/adapter/mailstore/maintenance/__init__.py b/service/pixelated/adapter/mailstore/maintenance/__init__.py
index edc442c2..9b6d6023 100644
--- a/service/pixelated/adapter/mailstore/maintenance/__init__.py
+++ b/service/pixelated/adapter/mailstore/maintenance/__init__.py
@@ -13,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
-from leap.keymanager.keys import KEY_TYPE_KEY, KEY_PRIVATE_KEY, KEY_ID_KEY, KEY_ADDRESS_KEY
+from leap.keymanager.keys import KEY_TYPE_KEY, KEY_PRIVATE_KEY, KEY_FINGERPRINT_KEY, KEY_ADDRESS_KEY
from leap.keymanager.openpgp import OpenPGPKey
from twisted.internet import defer
@@ -44,8 +44,8 @@ def _is_public_key(doc):
return _is_key_doc(doc) and not doc.content.get(KEY_PRIVATE_KEY, False)
-def _key_id(doc):
- return doc.content.get(KEY_ID_KEY, None)
+def _key_fingerprint(doc):
+ return doc.content.get(KEY_FINGERPRINT_KEY, None)
def _address(doc):
@@ -60,40 +60,41 @@ class SoledadMaintenance(object):
def repair(self):
_, docs = yield self._soledad.get_all_docs()
- private_key_ids = self._key_ids_with_private_key(docs)
+ private_key_fingerprints = self._key_fingerprints_with_private_key(docs)
for doc in docs:
- if _is_key_doc(doc) and _key_id(doc) not in private_key_ids:
- logger.warn('Deleting doc %s for key %s of <%s>' % (doc.doc_id, _key_id(doc), _address(doc)))
+ if _is_key_doc(doc) and _key_fingerprint(doc) not in private_key_fingerprints:
+ logger.warn('Deleting doc %s for key %s of <%s>' % (doc.doc_id, _key_fingerprint(doc), _address(doc)))
yield self._soledad.delete_doc(doc)
- yield self._repair_missing_active_docs(docs, private_key_ids)
+ yield self._repair_missing_active_docs(docs, private_key_fingerprints)
@defer.inlineCallbacks
- def _repair_missing_active_docs(self, docs, private_key_ids):
- missing = self._missing_active_docs(docs, private_key_ids)
- for key_id in missing:
- emails = self._emails_for_key_id(docs, key_id)
+ def _repair_missing_active_docs(self, docs, private_key_fingerprints):
+ missing = self._missing_active_docs(docs, private_key_fingerprints)
+ for fingerprint in missing:
+ emails = self._emails_for_key_fingerprint(docs, fingerprint)
for email in emails:
- logger.warn('Re-creating active doc for key %s, email %s' % (key_id, email))
- yield self._soledad.create_doc_from_json(OpenPGPKey(email, key_id=key_id, private=False).get_active_json(email))
+ logger.warn('Re-creating active doc for key %s, email %s' % (fingerprint, email))
+ yield self._soledad.create_doc_from_json(OpenPGPKey(email, fingerprint=fingerprint, private=False).get_active_json())
- def _key_ids_with_private_key(self, docs):
- return [doc.content[KEY_ID_KEY] for doc in docs if _is_private_key_doc(doc)]
+ def _key_fingerprints_with_private_key(self, docs):
+ return [doc.content[KEY_FINGERPRINT_KEY] for doc in docs if _is_private_key_doc(doc)]
- def _missing_active_docs(self, docs, private_key_ids):
- active_doc_ids = self._active_docs_for_key_id(docs)
+ def _missing_active_docs(self, docs, private_key_fingerprints):
+ active_doc_ids = self._active_docs_for_key_fingerprint(docs)
- return set([private_key_id for private_key_id in private_key_ids if private_key_id not in active_doc_ids])
+ return set([private_key_fingerprint for private_key_fingerprint in private_key_fingerprints if private_key_fingerprint not in active_doc_ids])
- def _emails_for_key_id(self, docs, key_id):
+ def _emails_for_key_fingerprint(self, docs, fingerprint):
for doc in docs:
- if _is_private_key_doc(doc) and _key_id(doc) == key_id:
+ if _is_private_key_doc(doc) and _key_fingerprint(doc) == fingerprint:
email = _address(doc)
+ if email is None:
+ return []
if isinstance(email, list):
return email
- else:
- return [email]
+ return [email]
- def _active_docs_for_key_id(self, docs):
- return [doc.content[KEY_ID_KEY] for doc in docs if _is_active_key_doc(doc) and _is_public_key(doc)]
+ def _active_docs_for_key_fingerprint(self, docs):
+ return [doc.content[KEY_FINGERPRINT_KEY] for doc in docs if _is_active_key_doc(doc) and _is_public_key(doc)]
diff --git a/service/pixelated/adapter/search/__init__.py b/service/pixelated/adapter/search/__init__.py
index e137b392..3ec6532b 100644
--- a/service/pixelated/adapter/search/__init__.py
+++ b/service/pixelated/adapter/search/__init__.py
@@ -30,6 +30,7 @@ from whoosh.writing import AsyncWriter
from whoosh import sorting
from pixelated.support.functional import unique, to_unicode
import traceback
+from pixelated.support import date
class SearchEngine(object):
@@ -128,7 +129,7 @@ class SearchEngine(object):
index_data = {
'sender': self._empty_string_to_none(header.get('from', '')),
'subject': self._empty_string_to_none(header.get('subject', '')),
- 'date': self._format_utc_integer(header.get('date', '')),
+ 'date': self._format_utc_integer(header.get('date', date.mail_date_now())),
'to': self._format_recipient(header, 'to'),
'cc': self._format_recipient(header, 'cc'),
'bcc': self._format_recipient(header, 'bcc'),
diff --git a/service/pixelated/resources/keys_resource.py b/service/pixelated/resources/keys_resource.py
index d6f469fe..9075ab9e 100644
--- a/service/pixelated/resources/keys_resource.py
+++ b/service/pixelated/resources/keys_resource.py
@@ -17,7 +17,7 @@ class KeysResource(BaseResource):
if key.private:
respond_json_deferred(None, request, status_code=401)
else:
- respond_json_deferred(key.get_json(), request)
+ respond_json_deferred(key.get_active_json(), request)
def key_not_found(_):
respond_json_deferred(None, request, status_code=404)
diff --git a/service/pixelated/resources/logout_resource.py b/service/pixelated/resources/logout_resource.py
index 344ad2e9..01092b05 100644
--- a/service/pixelated/resources/logout_resource.py
+++ b/service/pixelated/resources/logout_resource.py
@@ -1,5 +1,8 @@
+from twisted.web.server import NOT_DONE_YET
+
from pixelated.resources import BaseResource
from twisted.web import util
+from twisted.internet import defer
from pixelated.resources.login_resource import LoginResource
@@ -8,9 +11,19 @@ class LogoutResource(BaseResource):
BASE_URL = "logout"
isLeaf = True
- def render_POST(self, request):
+ @defer.inlineCallbacks
+ def _execute_logout(self, request):
session = self.get_session(request)
- self._services_factory.log_out_user(session.user_uuid)
+ yield self._services_factory.log_out_user(session.user_uuid)
session.expire()
- return util.redirectTo("/%s" % LoginResource.BASE_URL, request)
+ def render_POST(self, request):
+ def _redirect_to_login(_):
+ content = util.redirectTo("/%s" % LoginResource.BASE_URL, request)
+ request.write(content)
+ request.finish()
+
+ d = self._execute_logout(request)
+ d.addCallback(_redirect_to_login)
+
+ return NOT_DONE_YET
diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py
index 86435d89..109dc08e 100644
--- a/service/pixelated/resources/root_resource.py
+++ b/service/pixelated/resources/root_resource.py
@@ -20,6 +20,7 @@ from string import Template
from pixelated.resources import BaseResource, UnAuthorizedResource
from pixelated.resources.attachments_resource import AttachmentsResource
+from pixelated.resources.sandbox_resource import SandboxResource
from pixelated.resources.contacts_resource import ContactsResource
from pixelated.resources.features_resource import FeaturesResource
from pixelated.resources.feedback_resource import FeedbackResource
@@ -75,6 +76,7 @@ class RootResource(BaseResource):
return csrf_input and csrf_input == xsrf_token
def initialize(self, portal=None, disclaimer_banner=None):
+ self._child_resources.add('sandbox', SandboxResource(self._static_folder))
self._child_resources.add('assets', File(self._static_folder))
self._child_resources.add('keys', KeysResource(self._services_factory))
self._child_resources.add(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory))
diff --git a/service/pixelated/resources/sandbox_resource.py b/service/pixelated/resources/sandbox_resource.py
new file mode 100644
index 00000000..28e8c9be
--- /dev/null
+++ b/service/pixelated/resources/sandbox_resource.py
@@ -0,0 +1,34 @@
+#
+# Copyright (c) 2016 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.web.static import File
+
+
+class SandboxResource(File):
+ CSP_HEADER_VALUES = "sandbox allow-popups allow-scripts;" \
+ "default-src 'self';" \
+ "style-src *;" \
+ "script-src *;" \
+ "font-src *;" \
+ "img-src *;" \
+ "object-src 'none';" \
+ "connect-src 'none';"
+
+ def render_GET(self, request):
+ request.setHeader('Content-Security-Policy', self.CSP_HEADER_VALUES)
+ request.setHeader('X-Content-Security-Policy', self.CSP_HEADER_VALUES)
+ request.setHeader('X-Webkit-CSP', self.CSP_HEADER_VALUES)
+ return super(SandboxResource, self).render_GET(request)
diff --git a/service/requirements.txt b/service/requirements.txt
index 1966f09a..b74b7f94 100644
--- a/service/requirements.txt
+++ b/service/requirements.txt
@@ -4,15 +4,15 @@ https://launchpad.net/dirspec/stable-13-10/13.10/+download/dirspec-13.10.tar.gz
--allow-external dirspec --allow-unverified dirspec
https://launchpad.net/u1db/stable-13-10/13.10/+download/u1db-13.10.tar.bz2
pyasn1==0.1.8
-requests==2.0.0
+requests==2.9.1
srp==1.0.4
whoosh==2.5.7
pycryptopp
--e 'git+https://github.com/pixelated-project/leap_pycommon.git@develop#egg=leap.common'
--e 'git+https://github.com/pixelated-project/leap_auth.git#egg=leap.auth'
--e 'git+https://github.com/pixelated-project/soledad.git@develop#egg=leap.soledad.common&subdirectory=common/'
--e 'git+https://github.com/pixelated-project/soledad.git@develop#egg=leap.soledad.client&subdirectory=client/'
--e 'git+https://github.com/pixelated-project/soledad.git@develop#egg=leap.soledad.server&subdirectory=server/'
--e 'git+https://github.com/pixelated-project/keymanager.git@develop#egg=leap.keymanager'
--e 'git+https://github.com/pixelated-project/leap_mail.git@develop#egg=leap.mail'
+-e 'git+https://github.com/pixelated/leap_pycommon.git@develop#egg=leap.common'
+-e 'git+https://github.com/pixelated/leap_auth.git#egg=leap.auth'
+-e 'git+https://github.com/pixelated/soledad.git@develop#egg=leap.soledad.common&subdirectory=common/'
+-e 'git+https://github.com/pixelated/soledad.git@develop#egg=leap.soledad.client&subdirectory=client/'
+-e 'git+https://github.com/pixelated/soledad.git@develop#egg=leap.soledad.server&subdirectory=server/'
+-e 'git+https://github.com/pixelated/keymanager.git@develop#egg=leap.keymanager'
+-e 'git+https://github.com/pixelated/leap_mail.git@develop#egg=leap.mail'
-e .
diff --git a/service/test/functional/features/steps/attachments.py b/service/test/functional/features/steps/attachments.py
index 76e42177..8fa032df 100644
--- a/service/test/functional/features/steps/attachments.py
+++ b/service/test/functional/features/steps/attachments.py
@@ -51,7 +51,7 @@ def load_mail_into_soledad(context, mail):
@then(u'I see the mail has an attachment')
def step_impl(context):
- attachments_list = find_elements_by_css_selector(context, '.attachmentsArea li')
+ attachments_list = find_elements_by_css_selector(context, '.mail-read-view__attachments-item')
assert len(attachments_list) == 1
diff --git a/service/test/functional/features/steps/mail_view.py b/service/test/functional/features/steps/mail_view.py
index 82fc28af..565031b5 100644
--- a/service/test/functional/features/steps/mail_view.py
+++ b/service/test/functional/features/steps/mail_view.py
@@ -19,14 +19,17 @@ from common import *
@then('I see that the subject reads \'{subject}\'')
def impl(context, subject):
- e = find_element_by_css_selector(context, '#mail-view .subject')
+ e = find_element_by_css_selector(context, '#mail-view .mail-read-view__header-subject')
assert e.text == subject
@then('I see that the body reads \'{expected_body}\'')
def impl(context, expected_body):
- e = find_element_by_css_selector(context, '#mail-view .bodyArea')
+ find_element_by_css_selector(context, '#read-sandbox')
+ context.browser.switch_to_frame('read-sandbox')
+ e = find_element_by_css_selector(context, 'body')
assert e.text == expected_body
+ context.browser.switch_to_default_content()
@then('that email has the \'{tag}\' tag')
@@ -97,13 +100,13 @@ def impl(context):
@when('I choose to trash')
def impl(context):
context.browser.execute_script("$('button#view-more-actions').click()")
- click_button(context, 'Delete this message', 'span')
+ click_button(context, 'Delete this message', 'li')
@then('I see the mail has a cc and a bcc recipient')
def impl(context):
- cc = find_element_by_css_selector(context, '.msg-header .cc')
- bcc = find_element_by_css_selector(context, '.msg-header .bcc')
+ cc = find_element_by_css_selector(context, '.mail-read-view__header-recipients .cc')
+ bcc = find_element_by_css_selector(context, '.mail-read-view__header-recipients .bcc')
assert cc is not None
assert bcc is not None
diff --git a/service/test/load/locustfile.py b/service/test/load/locustfile.py
index 68e39433..0c2ed518 100644
--- a/service/test/load/locustfile.py
+++ b/service/test/load/locustfile.py
@@ -1,6 +1,5 @@
import os
import json
-import time
from random import randint
from leap.auth import SRPAuth
@@ -9,9 +8,13 @@ from locust import HttpLocust, TaskSet, task
from pixelated.resources.login_resource import LoginResource
LEAP_PROVIDER = os.environ.get('LEAP_PROVIDER', 'dev.pixelated-project.org')
-LEAP_SERVER_HOST = os.environ.get('LEAP_SERVER_HOST', 'https://api.%s:4430' % LEAP_PROVIDER)
-LEAP_VERIFY_CERTIFICATE = os.environ.get('LEAP_VERIFY_CERTIFICATE', '~/.leap/ca.crt')
-MAX_NUMBER_USER = os.environ.get('MAX_NUMBER_USER', 10000)
+LEAP_SERVER_HOST = os.environ.get(
+ 'LEAP_SERVER_HOST',
+ 'https://api.%s:4430' % LEAP_PROVIDER)
+LEAP_VERIFY_CERTIFICATE = os.environ.get(
+ 'LEAP_VERIFY_CERTIFICATE',
+ '~/.leap/ca.crt')
+MAX_NUMBER_USER = os.environ.get('MAX_NUMBER_USER', 100)
INVITES_FILENAME = os.environ.get('INVITES_FILENAME', '/tmp/invite_codes.txt')
INVITES_ENABLED = os.environ.get('INVITES_ENABLED', 'true') == 'true'
@@ -23,53 +26,94 @@ def load_invite_from_number(number):
class UserBehavior(TaskSet):
+ def __init__(self, *args, **kwargs):
+ super(UserBehavior, self).__init__(*args, **kwargs)
+ self.cookies = {}
+
def on_start(self):
- """ on_start is called when a Locust start before any task is scheduled """
self.login()
def _get_or_create_user(self, number):
- srp_auth = SRPAuth(LEAP_SERVER_HOST, os.path.expanduser(LEAP_VERIFY_CERTIFICATE))
+ srp_auth = SRPAuth(
+ LEAP_SERVER_HOST,
+ os.path.expanduser(LEAP_VERIFY_CERTIFICATE))
username, password = ('loadtest%d' % number), ('password_%d' % number)
try:
srp_auth.authenticate(username, password)
except SRPAuthenticationError:
- invite_code = load_invite_from_number(number) if INVITES_ENABLED else None
+ invite_code = None
+ if INVITES_ENABLED:
+ invite_code = load_invite_from_number(number)
+
srp_auth.register(username, password, invite_code)
return username, password
def login(self):
number = randint(1, int(MAX_NUMBER_USER))
username, password = self._get_or_create_user(number)
- self.client.post("/%s" % LoginResource.BASE_URL, {"username": username, "password": password})
+ response = self.client.post(
+ "/%s" % LoginResource.BASE_URL,
+ {"username": username, "password": password},
+ verify=False)
+ self.cookies.update(response.cookies.get_dict())
+ resp = self.client.get("/", verify=False)
+ self.cookies.update(resp.cookies.get_dict())
self.username = username
- time.sleep(5)
@task(1)
def index(self):
- self.client.get("/")
+ self.client.get("/", verify=False)
@task(2)
def mail_box(self):
- self.client.get("/mails?q=tag:'inbox'&p=1&w=25")
+ self.client.get("/mails?q=tag:'inbox'&p=1&w=25", verify=False)
@task(3)
def send_mail(self):
- payload = {"tags": ["drafts"], "body": "some text lorem ipsum", "attachments": [], "ident": "",
- "header": {"to": ["%s@%s" % (self.username, LEAP_PROVIDER)], "cc": [], "bcc": [], "subject": "load testing"}}
- with self.client.post('/mails', json=payload, catch_response=True) as email_response:
+ payload = {
+ "tags": ["drafts"],
+ "body": "some text lorem ipsum",
+ "attachments": [],
+ "ident": "",
+ "header": {
+ "to": ["%s@%s" % (self.username, LEAP_PROVIDER)],
+ "cc": [],
+ "bcc": [],
+ "subject": "load testing"}}
+
+ self.cookies.update(
+ self.client.get("/", verify=False).cookies.get_dict())
+ print(self.cookies)
+ with self.client.post(
+ '/mails',
+ json=payload,
+ catch_response=True,
+ cookies=self.cookies,
+ headers={
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-XSRF-TOKEN': self.cookies['XSRF-TOKEN']}) as email_response:
if email_response.status_code == 201:
email_id = json.loads(email_response.content)['ident']
print email_id
self.delete_mail(email_id)
else:
- email_response.failure('Error: email not Sent, status code: %s' % email_response.status_code)
+ email_response.failure(
+ 'Error: email not Sent, status code: %s' % (
+ email_response.status_code))
def delete_mail(self, ident):
payload = {"idents": [ident]}
- self.client.post('/mails/delete', json=payload)
+ self.client.post(
+ '/mails/delete',
+ json=payload,
+ cookies=self.cookies,
+ verify=False,
+ headers={
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-XSRF-TOKEN': self.cookies['XSRF-TOKEN']})
class WebsiteUser(HttpLocust):
task_set = UserBehavior
- min_wait = 3000
+ min_wait = 5000
max_wait = 15000
diff --git a/service/test/unit/adapter/mailstore/maintenance/test_soledad_maintenance.py b/service/test/unit/adapter/mailstore/maintenance/test_soledad_maintenance.py
index e46d6864..be73af93 100644
--- a/service/test/unit/adapter/mailstore/maintenance/test_soledad_maintenance.py
+++ b/service/test/unit/adapter/mailstore/maintenance/test_soledad_maintenance.py
@@ -26,7 +26,7 @@ logging.getLogger('pixelated.adapter.mailstore.maintenance').addHandler(logging.
SOME_EMAIL_ADDRESS = 'foo@example.tld'
-SOME_KEY_ID = '4914254E384E264C'
+SOME_FINGERPRINT = '4914254E384E264C'
class TestSoledadMaintenance(unittest.TestCase):
@@ -42,8 +42,8 @@ class TestSoledadMaintenance(unittest.TestCase):
@defer.inlineCallbacks
def test_repair_delete_public_key_active_docs(self):
soledad = mock()
- key = self._public_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json(SOME_EMAIL_ADDRESS))
+ key = self._public_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json())
when(soledad).get_all_docs().thenReturn(defer.succeed((1, [active_doc])))
yield SoledadMaintenance(soledad).repair()
@@ -53,8 +53,8 @@ class TestSoledadMaintenance(unittest.TestCase):
@defer.inlineCallbacks
def test_repair_delete_public_key_docs(self):
soledad = mock()
- key = self._public_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json(SOME_EMAIL_ADDRESS))
+ key = self._public_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json())
key_doc = SoledadDocument(doc_id='some_doc', json=key.get_json())
when(soledad).get_all_docs().thenReturn(defer.succeed((1, [key_doc, active_doc])))
@@ -66,9 +66,9 @@ class TestSoledadMaintenance(unittest.TestCase):
@defer.inlineCallbacks
def test_repair_keeps_active_and_key_doc_if_private_key_exists(self):
soledad = mock()
- key = self._public_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- private_key = self._private_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json(SOME_EMAIL_ADDRESS))
+ key = self._public_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ private_key = self._private_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ active_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json())
key_doc = SoledadDocument(doc_id='some_doc', json=key.get_json())
private_key_doc = SoledadDocument(doc_id='some_doc', json=private_key.get_json())
when(soledad).get_all_docs().thenReturn(defer.succeed((1, [key_doc, active_doc, private_key_doc])))
@@ -82,8 +82,8 @@ class TestSoledadMaintenance(unittest.TestCase):
@defer.inlineCallbacks
def test_repair_only_deletes_key_docs(self):
soledad = mock()
- key = self._public_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- key_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json(SOME_EMAIL_ADDRESS))
+ key = self._public_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ key_doc = SoledadDocument(doc_id='some_doc', json=key.get_active_json())
other_doc = SoledadDocument(doc_id='something', json='{}')
when(soledad).get_all_docs().thenReturn(defer.succeed((1, [key_doc, other_doc])))
@@ -95,19 +95,19 @@ class TestSoledadMaintenance(unittest.TestCase):
def test_repair_recreates_public_key_active_doc_if_necessary(self):
soledad = mock()
- private_key = self._private_key(SOME_EMAIL_ADDRESS, SOME_KEY_ID)
- private_key_doc = SoledadDocument(doc_id='some_doc', json=private_key.get_json())
+ private_key = self._private_key(SOME_EMAIL_ADDRESS, SOME_FINGERPRINT)
+ private_key_doc = SoledadDocument(doc_id='some_doc', json=private_key.get_active_json())
when(soledad).get_all_docs().thenReturn(defer.succeed((1, [private_key_doc])))
yield SoledadMaintenance(soledad).repair()
- verify(soledad).create_doc_from_json('{"key_id": "4914254E384E264C", "tags": ["keymanager-active"], "type": "OpenPGPKey-active", "private": false, "address": "foo@example.tld"}')
+ verify(soledad).create_doc_from_json('{"encr_used": false, "sign_used": false, "validation": "Weak_Chain", "version": 1, "address": "foo@example.tld", "last_audited_at": 0, "fingerprint": "4914254E384E264C", "type": "OpenPGPKey-active", "private": false, "tags": ["keymanager-active"]}')
- def _public_key(self, address, keyid):
- return self._gpgkey(address, keyid, private=False)
+ def _public_key(self, address, fingerprint):
+ return self._gpgkey(address, fingerprint, private=False)
- def _private_key(self, address, keyid):
- return self._gpgkey(address, keyid, private=True)
+ def _private_key(self, address, fingerprint):
+ return self._gpgkey(address, fingerprint, private=True)
- def _gpgkey(self, address, keyid, private=False):
- return OpenPGPKey(address, key_id=keyid, private=private)
+ def _gpgkey(self, address, fingerprint, private=False):
+ return OpenPGPKey(address, fingerprint=fingerprint, private=private)
diff --git a/service/test/unit/resources/test_keys_resources.py b/service/test/unit/resources/test_keys_resources.py
index 6aa822e1..2bf53cb4 100644
--- a/service/test/unit/resources/test_keys_resources.py
+++ b/service/test/unit/resources/test_keys_resources.py
@@ -44,20 +44,16 @@ class TestKeysResource(unittest.TestCase):
d = self.web.get(request)
expected = {
- "tags": ["keymanager-key"],
- "fingerprint": '',
- "private": False,
- 'sign_used': False,
- 'refreshed_at': 0,
- "expiry_date": 0,
- "address": 'some@key',
- 'encr_used': False,
- 'last_audited_at': 0,
- 'key_data': '',
- 'length': 0,
- 'key_id': '',
- 'validation': 'Weak_Chain',
- 'type': 'OpenPGPKey',
+ u'address': u'some@key',
+ u'encr_used': False,
+ u'fingerprint': u'',
+ u'last_audited_at': 0,
+ u'private': False,
+ u'sign_used': False,
+ u'tags': [u'keymanager-active'],
+ u'type': u'OpenPGPKey-active',
+ u'validation': u'Weak_Chain',
+ u'version': 1,
}
def assert_response(_):
diff --git a/service/test/unit/resources/test_logout_resources.py b/service/test/unit/resources/test_logout_resources.py
index 6246eeb9..312d2ba4 100644
--- a/service/test/unit/resources/test_logout_resources.py
+++ b/service/test/unit/resources/test_logout_resources.py
@@ -1,6 +1,6 @@
-from mock import patch
-from mockito import mock, verify
+from mock import patch, MagicMock
from twisted.trial import unittest
+from twisted.internet import defer
from twisted.web.error import UnsupportedMethod
from twisted.web.test.requesthelper import DummyRequest
@@ -10,8 +10,9 @@ from test.unit.resources import DummySite
class TestLogoutResource(unittest.TestCase):
def setUp(self):
- self.services_factory = mock()
+ self.services_factory = MagicMock()
self.resource = LogoutResource(self.services_factory)
+ self.services_factory.log_out_user.return_value = defer.succeed(None)
self.web = DummySite(self.resource)
@patch('twisted.web.util.redirectTo')
@@ -19,14 +20,16 @@ class TestLogoutResource(unittest.TestCase):
request = DummyRequest(['/logout'])
request.method = 'POST'
- mock_redirect.return_value = 'haha'
+ session = self.resource.get_session(request)
+ session.expire = MagicMock()
+ mock_redirect.return_value = 'some redirect response'
d = self.web.get(request)
def expire_session_and_redirect(_):
session = self.resource.get_session(request)
- self.assertFalse(session.is_logged_in())
- verify(self.services_factory).log_out_user(session.user_uuid)
+ self.services_factory.log_out_user.assert_called_once_with(session.user_uuid)
+ session.expire.assert_called_once_with()
mock_redirect.assert_called_once_with('/login', request)
d.addCallback(expire_session_and_redirect)
diff --git a/service/test/unit/resources/test_sandbox_resource.py b/service/test/unit/resources/test_sandbox_resource.py
new file mode 100644
index 00000000..3db43e12
--- /dev/null
+++ b/service/test/unit/resources/test_sandbox_resource.py
@@ -0,0 +1,38 @@
+import os
+import unittest
+
+from twisted.internet import defer
+from twisted.web.test.requesthelper import DummyRequest
+
+from pixelated.resources.sandbox_resource import SandboxResource
+from test.unit.resources import DummySite
+
+
+class TestSandBoxResource(unittest.TestCase):
+ def setUp(self):
+ static_folder = os.path.dirname(os.path.abspath(__file__))
+ self.resource = SandboxResource(static_folder)
+ self.resource.isLeaf = True
+ self.web = DummySite(self.resource)
+
+ @defer.inlineCallbacks
+ def test_render_GET_should_set_sandbox_csp_header(self):
+ request = DummyRequest(['/sandbox'])
+ request.method = 'GET'
+ request.isSecure = lambda: True
+ request.redirect = lambda _: 'irrelevant'
+
+ expected_csp_headers = "sandbox allow-popups allow-scripts;" \
+ "default-src 'self';" \
+ "style-src *;" \
+ "script-src *;" \
+ "font-src *;" \
+ "img-src *;" \
+ "object-src 'none';" \
+ "connect-src 'none';"
+
+ yield self.web.get(request)
+
+ self.assertEquals(expected_csp_headers, request.outgoingHeaders.get('X-Content-Security-Policy'.lower()))
+ self.assertEquals(expected_csp_headers, request.outgoingHeaders.get('Content-Security-Policy'.lower()))
+ self.assertEquals(expected_csp_headers, request.outgoingHeaders.get('X-Webkit-CSP'.lower()))
diff --git a/service/test_requirements.txt b/service/test_requirements.txt
index 3d4c73cb..52278140 100644
--- a/service/test_requirements.txt
+++ b/service/test_requirements.txt
@@ -10,4 +10,5 @@ tempdir==0.6
coverage
crochet==1.4.0
poster==0.8.1
-locustio==0.7.3 \ No newline at end of file
+locustio==0.7.3
+Twisted==15.5.0
diff --git a/web-ui/app/index.html b/web-ui/app/index.html
index c8a1abb5..4b6a81a0 100644
--- a/web-ui/app/index.html
+++ b/web-ui/app/index.html
@@ -9,8 +9,7 @@
<meta name="viewport" content="width=device-width">
<link href="assets/bower_components/font-awesome/css/font-awesome.min.css" rel="stylesheet" type="text/css">
<link href="assets/bower_components/jquery-file-upload/css/jquery.fileupload.css" rel="stylesheet" type="text/css">
-<link href="assets/css/opensans.css" rel="stylesheet" type="text/css">
-<link rel="stylesheet" href="assets/css/main.css">
+<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
@@ -62,7 +61,7 @@
</div>
<div class="off-canvas-wrap content" data-offcanvas>
- <header id="main" >
+ <header class="message-panel-container" >
<div id="user-alerts" class="message-panel"></div>
</header>
@@ -106,6 +105,7 @@
<script src="assets/bower_components/foundation/js/foundation/foundation.reveal.js" ></script>
<script src="assets/bower_components/foundation/js/foundation/foundation.offcanvas.js"></script>
<script src="assets/js/foundation/initialize_foundation.js"></script>
+<script src="assets/bower_components/iframe-resizer/js/iframeResizer.min.js"></script>
<script src="assets/bower_components/requirejs/require.js" data-main="assets/js/main.js"></script>
<!--usemin_end-->
diff --git a/web-ui/app/js/helpers/sanitizer.js b/web-ui/app/js/helpers/sanitizer.js
index eea1f0f7..443e8602 100644
--- a/web-ui/app/js/helpers/sanitizer.js
+++ b/web-ui/app/js/helpers/sanitizer.js
@@ -23,6 +23,16 @@ define(['DOMPurify', 'he'], function (DOMPurify, he) {
*/
var sanitizer = {};
+ sanitizer.whitelist = [{
+ // highlight tag open
+ pre: '&#x3C;&#x65;&#x6D;&#x20;&#x63;&#x6C;&#x61;&#x73;&#x73;&#x3D;&#x22;&#x73;&#x65;&#x61;&#x72;&#x63;&#x68;&#x2D;&#x68;&#x69;&#x67;&#x68;&#x6C;&#x69;&#x67;&#x68;&#x74;&#x22;&#x3E;',
+ post: '<em class="search-highlight">'
+ }, {
+ // highlight tag close
+ pre: '&#x3C;&#x2F;&#x65;&#x6D;&#x3E;',
+ post: '</em>'
+ }];
+
/**
* Adds html line breaks to a plaintext with line breaks (incl carriage return)
*
@@ -55,16 +65,24 @@ define(['DOMPurify', 'he'], function (DOMPurify, he) {
};
/**
- * Runs a given dirty body through he, thereby encoding everything
- * as HTML entities.
- *
- * @param {string} dirtyBody The unsanitized string
- * @return {string} Safe-to-display HTML string
- */
+ * Runs a given dirty body through he, thereby encoding everything
+ * as HTML entities.
+ *
+ * @param {string} dirtyBody The unsanitized string
+ * @return {string} Safe-to-display HTML string
+ */
sanitizer.purifyText = function (dirtyBody) {
- return he.encode(dirtyBody, {
+ var escapedBody = he.encode(dirtyBody, {
encodeEverything: true
});
+
+ this.whitelist.forEach(function(entry) {
+ while (escapedBody.indexOf(entry.pre) > -1) {
+ escapedBody = escapedBody.replace(entry.pre, entry.post);
+ }
+ });
+
+ return escapedBody;
};
/**
diff --git a/web-ui/app/js/mail_list/ui/mail_list.js b/web-ui/app/js/mail_list/ui/mail_list.js
index 18d36049..0f6c4fb5 100644
--- a/web-ui/app/js/mail_list/ui/mail_list.js
+++ b/web-ui/app/js/mail_list/ui/mail_list.js
@@ -81,7 +81,6 @@ define(
this.renderMails = function (mails) {
_.each(mails, this.appendMail, this);
this.trigger(document, events.search.highlightResults, {where: '#mail-list'});
- this.trigger(document, events.search.highlightResults, {where: '.bodyArea'});
this.trigger(document, events.search.highlightResults, {where: '.subjectArea'});
this.trigger(document, events.search.highlightResults, {where: '.msg-header .recipients'});
};
diff --git a/web-ui/app/js/mail_view/ui/mail_view.js b/web-ui/app/js/mail_view/ui/mail_view.js
index 8465b45a..d952fed7 100644
--- a/web-ui/app/js/mail_view/ui/mail_view.js
+++ b/web-ui/app/js/mail_view/ui/mail_view.js
@@ -71,11 +71,55 @@ define(
attachments: attachments
}));
- this.$node.find('.bodyArea').html(viewHelpers.formatMailBody(data.mail));
+ var $iframe = $("#read-sandbox");
+ var iframe = $iframe[0];
+
+ var content = viewHelpers.formatMailBody(data.mail);
+
+ iframe.onload = function() {
+ // use iframe-resizer to dynamically adapt iframe size to its content
+ var config = {
+ resizedCallback: scaleToFit,
+ checkOrigin: false
+ };
+ $iframe.iFrameResize(config);
+
+ // transform scale iframe to fit container width
+ // necessary if iframe is wider than container
+ function scaleToFit() {
+ var parentWidth = $iframe.parent().width();
+ var w = $iframe.width();
+ var scale = 'none';
+
+ // only scale html mails
+ var mail = data.mail;
+ if (mail && mail.htmlBody && (w > parentWidth)) {
+ scale = parentWidth / w;
+ scale = 'scale(' + scale + ',' + scale + ')';
+ }
+
+ $iframe.css({
+ '-webkit-transform-origin': '0 0',
+ '-moz-transform-origin': '0 0',
+ '-ms-transform-origin': '0 0',
+ 'transform-origin': '0 0',
+ '-webkit-transform': scale,
+ '-moz-transform': scale,
+ '-ms-transform': scale,
+ 'transform': scale
+ });
+ }
+
+ iframe.contentWindow.postMessage({
+ html: content
+ }, '*');
+ };
+
+
- this.trigger(document, events.search.highlightResults, {where: '.bodyArea'});
this.trigger(document, events.search.highlightResults, {where: '.subjectArea'});
this.trigger(document, events.search.highlightResults, {where: '.msg-header .recipients'});
+ this.trigger(document, events.ui.replyBox.showReplyContainer);
this.attachTagCompletion(this.attr.mail);
@@ -213,9 +257,17 @@ define(
this.trigger(events.mail.want, {mail: this.attr.ident, caller: this});
};
+ this.highlightMailContent = function (event, data) {
+ // we can't directly manipulate the iFrame to highlight the content
+ // so we need to take an indirection where we directly manipulate
+ // the mail content to accomodate the highlighting
+ this.trigger(document, events.mail.highlightMailContent, data);
+ };
+
this.after('initialize', function () {
- this.on(this, events.mail.here, this.displayMail);
this.on(this, events.mail.notFound, this.openNoMessageSelectedPane);
+ this.on(this, events.mail.here, this.highlightMailContent);
+ this.on(document, events.mail.display, this.displayMail);
this.on(document, events.dispatchers.rightPane.clear, this.teardown);
this.on(document, events.mail.tags.updated, this.tagsUpdated);
this.on(document, events.mail.deleted, this.mailDeleted);
diff --git a/web-ui/app/js/mail_view/ui/reply_section.js b/web-ui/app/js/mail_view/ui/reply_section.js
index 46dfe863..cbe64205 100644
--- a/web-ui/app/js/mail_view/ui/reply_section.js
+++ b/web-ui/app/js/mail_view/ui/reply_section.js
@@ -36,7 +36,8 @@ define(
replyAllButton: '#reply-all-button',
forwardButton: '#forward-button',
replyBox: '#reply-box',
- replyType: 'reply'
+ replyType: 'reply',
+ replyContainer: '.reply-container'
});
this.showReply = function() {
@@ -64,9 +65,7 @@ define(
this.checkForDraftReply = function() {
this.render();
- this.select('replyButton').hide();
- this.select('replyAllButton').hide();
- this.select('forwardButton').hide();
+ this.hideContainer();
this.trigger(document, events.mail.draftReply.want, {ident: this.attr.ident});
};
@@ -76,11 +75,13 @@ define(
};
this.showDraftReply = function(ev, data) {
+ this.showContainer();
this.hideButtons();
ReplyBox.attachTo(this.select('replyBox'), { mail: data.mail, draftReply: true });
};
this.showReplyComposeBox = function (ev, data) {
+ this.showContainer();
this.hideButtons();
if(this.attr.replyType === 'forward') {
ForwardBox.attachTo(this.select('replyBox'), { mail: data.mail });
@@ -89,6 +90,14 @@ define(
}
};
+ this.hideContainer = function() {
+ this.select('replyContainer').hide();
+ };
+
+ this.showContainer = function() {
+ this.select('replyContainer').show();
+ };
+
this.hideButtons = function() {
this.select('replyButton').hide();
this.select('replyAllButton').hide();
@@ -96,6 +105,7 @@ define(
};
this.showButtons = function () {
+ this.showContainer();
this.select('replyBox').empty();
this.select('replyButton').show();
this.select('replyAllButton').show();
@@ -109,7 +119,7 @@ define(
this.on(this, events.mail.here, this.showReplyComposeBox);
this.on(document, events.dispatchers.rightPane.clear, this.teardown);
- this.on(document, events.mail.draftReply.notFound, this.showButtons);
+ this.on(document, events.ui.replyBox.showReplyContainer, this.showContainer);
this.on(document, events.mail.draftReply.here, this.showDraftReply);
this.checkForDraftReply();
diff --git a/web-ui/app/js/page/events.js b/web-ui/app/js/page/events.js
index 1f48173d..68a6aad1 100644
--- a/web-ui/app/js/page/events.js
+++ b/web-ui/app/js/page/events.js
@@ -81,7 +81,8 @@ define(function () {
},
replyBox: {
showReply: 'ui:replyBox:showReply',
- showReplyAll: 'ui:replyBox:showReplyAll'
+ showReplyAll: 'ui:replyBox:showReplyAll',
+ showReplyContainer: 'ui:replyBox:showReplyContainer',
},
recipients: {
entered: 'ui:recipients:entered',
@@ -122,6 +123,8 @@ define(function () {
mail: {
here: 'mail:here',
want: 'mail:want',
+ display: 'mail:display',
+ highlightMailContent: 'mail:highlightMailContent',
send: 'mail:send',
send_failed: 'mail:send_failed',
sent: 'mail:sent',
diff --git a/web-ui/app/js/sandbox.js b/web-ui/app/js/sandbox.js
new file mode 100644
index 00000000..f9e708d6
--- /dev/null
+++ b/web-ui/app/js/sandbox.js
@@ -0,0 +1,9 @@
+(function () {
+ 'use strict';
+
+ window.onmessage = function (e) {
+ if (e.data.html) {
+ document.body.innerHTML = e.data.html;
+ }
+ };
+})();
diff --git a/web-ui/app/js/search/results_highlighter.js b/web-ui/app/js/search/results_highlighter.js
index 9e3ba167..831be0cd 100644
--- a/web-ui/app/js/search/results_highlighter.js
+++ b/web-ui/app/js/search/results_highlighter.js
@@ -40,6 +40,7 @@ define(
var domIdent = data.where;
if(this.attr.keywords) {
_.each(this.attr.keywords, function (keyword) {
+ keyword = escapeRegExp(keyword);
$(domIdent).highlightRegex(new RegExp(keyword, 'i'), {
tagType: 'em',
className: 'search-highlight'
@@ -57,12 +58,40 @@ define(
});
};
+ this.highlightString = function (string) {
+ _.each(this.attr.keywords, function (keyword) {
+ keyword = escapeRegExp(keyword);
+ var regex = new RegExp('(' + keyword + ')', 'ig');
+ string = string.replace(regex, '<em class="search-highlight">$1</em>');
+ });
+ return string;
+ };
+
+ /*
+ * Alter data.mail.textPlainBody to highlight each of this.attr.keywords
+ * and pass it back to the mail_view when done
+ */
+ this.highlightMailContent = function(ev, data){
+ var mail = data.mail;
+ mail.textPlainBody = this.highlightString(mail.textPlainBody);
+ this.trigger(document, events.mail.display, data);
+ };
+
+ /*
+ * Escapes the special charaters used regular expressions that
+ * would cause problems with strings in the RegExp constructor
+ */
+ function escapeRegExp(string){
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
this.after('initialize', function () {
this.on(document, events.search.perform, this.getKeywordsSearch);
this.on(document, events.ui.tag.select, this.clearHighlights);
this.on(document, events.search.resetHighlight, this.clearHighlights);
this.on(document, events.search.highlightResults, this.highlightResults);
+ this.on(document, events.mail.highlightMailContent, this.highlightMailContent);
});
}
});
diff --git a/web-ui/app/js/user_alerts/ui/user_alerts.js b/web-ui/app/js/user_alerts/ui/user_alerts.js
index b02762aa..e944a7a5 100644
--- a/web-ui/app/js/user_alerts/ui/user_alerts.js
+++ b/web-ui/app/js/user_alerts/ui/user_alerts.js
@@ -32,20 +32,26 @@ define(
dismissTimeout: 3000
});
- this.render = function (message) {
+ this.render = function(message) {
this.$node.html(templates.userAlerts.message(message));
this.show();
setTimeout(this.hide.bind(this), this.attr.dismissTimeout);
};
- this.displayMessage = function (ev, data) {
- this.render({ message: {content: data.message, class: (data.class || 'success')}});
+ this.displayMessage = function(ev, data) {
+ this.render({
+ message: {
+ content: data.message,
+ class: 'message-panel__growl--' + (data.class || 'success')
+ }
+ });
};
- this.after('initialize', function () {
+ this.after('initialize', function() {
this.on(document, events.ui.userAlerts.displayMessage, this.displayMessage);
});
}
}
);
+
diff --git a/web-ui/app/sandbox.html b/web-ui/app/sandbox.html
new file mode 100644
index 00000000..13a86f25
--- /dev/null
+++ b/web-ui/app/sandbox.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8">
+ <link href="css/opensans.css" rel="stylesheet" type="text/css">
+ <link href="css/sandbox.css" rel="stylesheet" type="text/css">
+
+ <!--usemin_start-->
+ <script src="js/sandbox.js"></script>
+ <script src="bower_components/iframe-resizer/js/iframeResizer.contentWindow.min.js"></script>
+ <!--usemin_end-->
+</head>
+
+<body></body>
+
+</html>
diff --git a/web-ui/app/scss/_alerts.scss b/web-ui/app/scss/_alerts.scss
deleted file mode 100644
index 8d7396fc..00000000
--- a/web-ui/app/scss/_alerts.scss
+++ /dev/null
@@ -1,25 +0,0 @@
-.message-panel {
- width: 100%;
- margin: 10px auto;
- position: fixed;
- z-index: 10000;
- text-align: center;
- span{
- padding: 5px 60px;
- &.success {
- background: $warning;
- color: darken($warning, 50%);
- border: 1px solid darken($warning, 10%);
- @include box-shadow(1px 1px 3px darken($warning, 60%));
- }
- &.error {
- font-weight: bold;
- color: white;
- background: $error;
- border: 1px solid darken($error, 10%);
- @include box-shadow(1px 1px 3px darken($error, 60%));
- }
- }
-}
-
-@include logo;
diff --git a/web-ui/app/scss/_compose.scss b/web-ui/app/scss/_compose.scss
deleted file mode 100644
index 32d11c3d..00000000
--- a/web-ui/app/scss/_compose.scss
+++ /dev/null
@@ -1,185 +0,0 @@
-// COMPOSE BUTTON
-#compose {
- margin-bottom: 5px;
- padding-right: 4px;
- #compose-trigger {
- width: 100%;
- display: inline-block;
- padding: 5px;
- #compose-mails-trigger {
- background: $action_buttons;
- color: $white;
- padding: 10px 30px;
- text-align: center;
- font-weight: 400;
- font-size: 1.2em;
- @include btn-transition;
- &:hover {
- background: lighten($action_buttons, 10%);
- cursor: pointer;
- }
- }
- }
-}
-
-// COMPOSE PANE
-#compose-box, #draft-box, #reply-box, #feedback-box {
- div.floatlabel {
- position: relative;
- }
- margin: 5px 0 50px 30px;
- padding: 0;
- .input-container {
- padding: 1px;
- }
- label, span {
- color: $recipients_font_color;
- padding: 0.5rem;
- cursor: text;
- display: inline-block;
- }
-
- label {
- padding: 13px 10px;
- }
-
- span {
- padding: 3px;
-
- &.attachment-size {
- color: $attachment_size;
- cursor: pointer;
- }
- }
-
- label.floatlabel {
- padding: 0.4rem !important;
- position: absolute;
- font-size: 0.6rem;
- transition: all 0.1s linear;
- opacity: 0;
- font-weight: bold;
- }
- label.showfloatlabel {
- color: $light_blue !important;
- top: -0.3rem;
- opacity: 1;
- }
- input, textarea {
- margin: 0;
- border: none;
- transition: all 0.1s linear;
- }
- input.showfloatlabel, textarea.showfloatlabel {
- padding-top: 1rem !important;
- }
- input#subject, #feedback-subject {
- font-size: 1.6875rem;
- line-height: 1.4;
- border-top: 1px solid $lighter_gray;
- }
- #feedback-subject {
- color: $dark_grey;
- }
- textarea {
- border-bottom: 2px solid $lighter_gray;
- min-height: 400px;
- font-family: inherit;
- font-weight: normal;
- font-size: 1rem;
- line-height: 1.6;
- text-rendering: optimizeLegibility;
- }
-
- &.reply-box, &.forward-box {
- margin: 0;
- h4 {
- font-size: 0.9em;
- font-style: italic;
- color: $medium_grey;
- margin: 2px 0;
- clear: both;
- cursor: pointer;
- &:hover {
- background: $contrast;
- }
- }
- textarea {
- min-height: 200px;
- margin: 10px 0;
- }
- p {
- padding: 5px;
- margin: 10px 0;
- font-style: italic;
- cursor: pointer;
- &:hover {
- background: $contrast;
- }
- }
- }
-
- button.close-mail-button {
- margin: 1px;
- }
-
- .buttons-group {
- margin-top: 0px;
- }
-
- #attachment-upload-item {
- display: none;
-
- .progress {
- width: 0%;
- position: absolute;
- right: 0;
- left: 0;
- top: 0;
- bottom: 0;
- min-height: 100%;
-
- .progress-bar {
- height: 100%;
- background-color: rgba($light_blue, 0.3);
- }
-
- }
- }
-
- .attachmentsAreaWrap {
- padding: 0;
-
- .attachmentsArea {
- padding: 0;
- border-top: 0;
-
- #upload-error {
- color: $error;
- margin-bottom: 20px;
-
- .close-icon {
- font-size: 1.0rem;
- cursor: pointer;
- }
-
- span, a {
- color: $error;
- font-size: 0.9rem;
- }
-
- a {
- text-decoration: underline;
- padding: 5px;
- }
- }
-
- }
- }
-
- @include recipients;
-}
-
-#reply-box {
- @include recipients;
-}
diff --git a/web-ui/app/scss/_mascot.scss b/web-ui/app/scss/_mascot.scss
deleted file mode 100644
index 74279063..00000000
--- a/web-ui/app/scss/_mascot.scss
+++ /dev/null
@@ -1,47 +0,0 @@
-#no-message-selected-pane {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100vh;
-
- z-index: -100;
- background: $contrast;
- padding: 30px;
- vertical-align:middle;
- text-align:center;
- -webkit-transform: translate3d(0, 0, 0);
- &:before{
- content: '';
- display: inline-block;
- height: 100%;
- vertical-align: middle;
- margin-right: -0.25em;
- }
- .scene{
- display:inline-block;
- vertical-align:middle;
- }
-
- .text{
- color:$medium_dark_grey;
- margin-bottom: 40px;
- }
-}
-
-#no-mails-available-pane {
- text-align: center;
- line-height: 100vh;
- margin-top: -130px;
-
- .scene{
- display:inline-block;
- vertical-align:middle;
- }
-
- .text{
- color:$medium_dark_grey;
- margin-bottom: 40px;
- }
-
-}
diff --git a/web-ui/app/scss/_mixins.scss b/web-ui/app/scss/_mixins.scss
index 6d0bb1a3..13f90685 100644
--- a/web-ui/app/scss/_mixins.scss
+++ b/web-ui/app/scss/_mixins.scss
@@ -1,4 +1,4 @@
-@import 'colors';
+@import 'base/colors';
// SHARED MIXINS
@mixin btn-transition {
@@ -88,6 +88,7 @@
}
ul.tags {
+ margin-bottom: 0;
li {
font-size: 0.6rem;
background-color: lighten($action_buttons, 12);
@@ -161,7 +162,6 @@
}
}
-
@mixin recipients {
.recipients-area {
@@ -344,3 +344,4 @@
}
@include tt-hint;
+@include logo;
diff --git a/web-ui/app/scss/_others.scss b/web-ui/app/scss/_others.scss
new file mode 100644
index 00000000..e73ed33d
--- /dev/null
+++ b/web-ui/app/scss/_others.scss
@@ -0,0 +1,7 @@
+.no-padding {
+ padding: 0;
+}
+
+.text-right {
+ text-align: right;
+}
diff --git a/web-ui/app/scss/_read.scss b/web-ui/app/scss/_read.scss
deleted file mode 100644
index 7943d584..00000000
--- a/web-ui/app/scss/_read.scss
+++ /dev/null
@@ -1,164 +0,0 @@
-/* MAIL PANE */
-
-@mixin read-msg {
- #mail-view {
- .msg-header {
- display: flex;
- flex-wrap: nowrap;
-
- top: 0;
- z-index: 10;
- background-color: white;
- font-size: 0.9em;
- padding: 0px 0;
- margin: 1px 0 0 0;
- .recipients {
- padding-bottom: 5px;
- line-height: 1.5em;
- i {
- padding: 0 5px;
- }
- .from {
- font-weight: 700;
- }
- }
- .close-mail-button {
- position: relative;
- float: none;
- flex-shrink: 0;
- display: inline-block;
- vertical-align: top;
- height: 27px;
- margin-right: 3px;
- }
-
- .full-view-header {
- display:inline-block;
- padding-top: 5px;
- width:95%;
- flex-shrink:1;
- }
- .headline-area {
- clear: both;
- border-top: 1px solid $lighter_gray;
- }
- }
- h3 {
- margin-bottom: 0;
- }
- .tagsArea {
- clear: both;
- margin: 0 0 10px;
- @include tags;
- ul li {
- &.tag:hover {
- &:before {
- content: "click to remove";
- text-transform: lowercase;
- font-size: 0.5rem;
- @include tooltip(18px, 8px);
- }
- }
- }
- }
- }
-}
-
-.bodyArea {
- padding: 15px 30px 0 30px;
-}
-
-.attachmentsAreaWrap {
- padding: 0 30px;
-
- .attachmentsArea {
- border-top: 1px solid $lighter_gray;
- padding: 10px 0 0;
-
- li {
- position: relative;
- display: block;
- border: 1px solid $border_light_grey;
- border-radius: 2px;
- background-color: $background_light_grey;
- margin-bottom: 8px;
-
- a {
- color: $attachment_text;
- display: block;
- text-decoration: none;
- line-height: inherit;
- padding: 4px 5px;
-
- &:hover, &:focus {
- i.download-icon {
- color: lighten($attachment_icon, 15);
- }
-
- color: $attachment_icon;
- outline: none;
- }
-
- i.download-icon {
- position: absolute;
- color: $attachment_icon;
- right: 0;
- padding: 7px 10px;
- }
-
- i.remove-icon {
- float: right;
- padding-right: 5px;
- padding-top: 7px;
- color: $medium_light_grey;
- }
- }
- }
- }
-}
-
-#mail-actions {
- text-align: right;
- padding: 10px 0;
- button {
- display: inline-block;
- display: inline;
- line-height: 2em;
- border: 1px solid $lighter_gray;
- &#reply-button-top {
- @include border-right-radius(0);
- padding: 0 20px;
- }
- &#view-more-actions {
- @include border-left-radius(0);
- padding: 0 5px;
- margin-left: -4px;
- }
- &:hover {
- @include btn-transition;
- background: darken($contrast, 5%)
- }
- }
- ul#more-actions {
- padding: 5px 0;
- width: 170px;
- text-align: left;
- display: block;
- position: absolute;
- background: $white;
- border: 1px solid $lighter_gray;
- right: 0;
- top: 40px;
- z-index: 10;
- li {
- span, a {
- padding: 5px 10px;
- display: block;
- &:hover {
- cursor: pointer;
- background: $contrast;
- }
- }
- }
- }
-}
diff --git a/web-ui/app/scss/_reply.scss b/web-ui/app/scss/_reply.scss
index 76689d16..622bdd5a 100644
--- a/web-ui/app/scss/_reply.scss
+++ b/web-ui/app/scss/_reply.scss
@@ -1,6 +1,6 @@
#reply-section {
- padding: 0 30px;
+ padding-left: 30px;
.reply-container {
margin: 10px 0;
padding: 10px;
diff --git a/web-ui/app/scss/_security.scss b/web-ui/app/scss/_security.scss
index 8e9a6b5d..ff36cb3e 100644
--- a/web-ui/app/scss/_security.scss
+++ b/web-ui/app/scss/_security.scss
@@ -3,10 +3,11 @@
clear: both;
span {
display: inline-block;
- padding: 2px 5px;
+ padding: 2px 6px;
white-space: nowrap;
background: $success;
color: $white;
+ border-radius: 12px;
&:before {
font-family: FontAwesome;
}
diff --git a/web-ui/app/scss/_styles.scss b/web-ui/app/scss/_styles.scss
index 63f15f6a..98c70c68 100644
--- a/web-ui/app/scss/_styles.scss
+++ b/web-ui/app/scss/_styles.scss
@@ -528,10 +528,6 @@ section {
box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.12);
z-index: 2;
overflow-y: auto;
- @include read-msg;
- [id^=fullView-] {
- position: relative;
- }
}
}
@@ -578,9 +574,11 @@ section {
button {
border: 1px solid transparent;
+
i {
margin-left: 5px;
}
+
&#trash-button {
background: $white;
border: 1px solid $medium_light_grey;
@@ -591,30 +589,8 @@ button {
background: $contrast;
}
}
- &.close-mail-button {
- background: transparent;
- color: $medium_light_grey;
- float: right;
- &:hover {
- color: darken($medium_light_grey, 10%);
- }
- }
- &.close-mail-button {
- position: absolute;
- left: 0;
- top: 0;
- margin: 0;
- padding: 3px 6px 5px;
- background: $lighter_gray;
- opacity: 0.7;
- @include border-radius(0);
- &:hover {
- opacity: 1;
- }
- i {
- margin: 0;
- }
- }
+
+
&.no-style {
background: transparent;
color: $medium_light_grey;
diff --git a/web-ui/app/scss/_colors.scss b/web-ui/app/scss/base/_colors.scss
index de5f76b3..de5f76b3 100644
--- a/web-ui/app/scss/_colors.scss
+++ b/web-ui/app/scss/base/_colors.scss
diff --git a/web-ui/app/scss/opensans.scss b/web-ui/app/scss/base/_fonts.scss
index ada6a025..ada6a025 100644
--- a/web-ui/app/scss/opensans.scss
+++ b/web-ui/app/scss/base/_fonts.scss
diff --git a/web-ui/app/scss/base/_scaffolding.scss b/web-ui/app/scss/base/_scaffolding.scss
new file mode 100644
index 00000000..b8b5fa3b
--- /dev/null
+++ b/web-ui/app/scss/base/_scaffolding.scss
@@ -0,0 +1,10 @@
+html {
+ height: 100% ;
+}
+
+body {
+ min-height: 100% ;
+ overflow: hidden;
+ background: $white;
+}
+
diff --git a/web-ui/app/scss/main.scss b/web-ui/app/scss/main.scss
deleted file mode 100644
index b582a5d5..00000000
--- a/web-ui/app/scss/main.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-@import "compass/css3";
-@import "reset";
-@import "foundation";
-@import "colors";
-@import "mixins";
-@import "alerts";
-@import "read";
-@import "reply";
-@import "compose";
-@import "security";
-@import "mascot";
-@import "styles";
-
-html {
- height:100%;
-}
-body {
- min-height:100%;
- overflow: hidden;
- background: $white;
-}
-header#main {
- overflow: hidden;
- position: fixed;
- top: 0;
- width: 100%;
- position: relative;
- margin-bottom: 0;
-}
-
-.no-padding {
- padding: 0;
-}
-
-.text-right {
- text-align: right;
-}
diff --git a/web-ui/app/scss/mixins/_position-helpers.scss b/web-ui/app/scss/mixins/_position-helpers.scss
new file mode 100644
index 00000000..254bfc6c
--- /dev/null
+++ b/web-ui/app/scss/mixins/_position-helpers.scss
@@ -0,0 +1,9 @@
+@mixin absolute-center-unknown-height-width() {
+ margin: auto;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ -ms-transform: translate(-50%, -50%);
+ -webkit-transform: translate(-50%, -50%);
+ transform: translate(-50%, -50%);
+}
diff --git a/web-ui/app/scss/sandbox.scss b/web-ui/app/scss/sandbox.scss
new file mode 100644
index 00000000..3cb4c441
--- /dev/null
+++ b/web-ui/app/scss/sandbox.scss
@@ -0,0 +1,20 @@
+$search-highlight: #FFEF29;
+
+body {
+ font-family: "Open Sans", "Microsoft YaHei", "Hiragino Sans GB", "Hiragino Sans GB W3", "微软雅黑", "Helvetica Neue", Arial, sans-serif;
+ font-size: 13px;
+ line-height: 1.2em;
+ background: white;
+ color: #333;
+ padding: 0;
+ margin: 0;
+ font-weight: normal;
+ -webkit-font-smoothing: antialiased;
+ font-style: normal;
+ box-sizing: border-box;
+ word-wrap: break-word;
+}
+
+.search-highlight {
+ background-color: $search-highlight;
+}
diff --git a/web-ui/app/scss/style.scss b/web-ui/app/scss/style.scss
new file mode 100644
index 00000000..cb905c27
--- /dev/null
+++ b/web-ui/app/scss/style.scss
@@ -0,0 +1,35 @@
+// vendor stylesheets and resets
+@import "vendor/reset";
+@import "vendor/scut";
+@import "compass/css3";
+@import "vendor/foundation";
+
+// basic configuration
+@import "base/fonts";
+@import "base/colors";
+@import "base/scaffolding";
+
+// mixins
+@import "mixins/position-helpers";
+@import "mixins";
+
+// templates
+@import "templates/no-content-placeholder";
+
+// views
+@import "views/message-panel";
+@import "views/close-button";
+@import "views/no-message-selected";
+@import "views/no-mails-available";
+@import "views/read-view";
+@import "views/compose-view";
+@import "views/compose-button";
+
+// misc stuff
+@import "others";
+
+// TODO
+@import "reply";
+@import "security";
+@import "styles";
+
diff --git a/web-ui/app/scss/templates/_no-content-placeholder.scss b/web-ui/app/scss/templates/_no-content-placeholder.scss
new file mode 100644
index 00000000..c6807011
--- /dev/null
+++ b/web-ui/app/scss/templates/_no-content-placeholder.scss
@@ -0,0 +1,5 @@
+.no-content-placeholder {
+ @include absolute-center-unknown-height-width;
+
+ color: $medium_dark_grey;
+}
diff --git a/web-ui/app/scss/foundation.scss b/web-ui/app/scss/vendor/_foundation.scss
index 7918cf26..7918cf26 100644
--- a/web-ui/app/scss/foundation.scss
+++ b/web-ui/app/scss/vendor/_foundation.scss
diff --git a/web-ui/app/scss/reset.scss b/web-ui/app/scss/vendor/_reset.scss
index 55f8d054..55f8d054 100644
--- a/web-ui/app/scss/reset.scss
+++ b/web-ui/app/scss/vendor/_reset.scss
diff --git a/web-ui/app/scss/vendor/_scut.scss b/web-ui/app/scss/vendor/_scut.scss
new file mode 100644
index 00000000..3e16fa65
--- /dev/null
+++ b/web-ui/app/scss/vendor/_scut.scss
@@ -0,0 +1,1518 @@
+/*
+* Scut, a collection of Sass utilities
+* to ease and improve our implementations of common style-code patterns.
+* v1.3.0
+* Docs at http://davidtheclark.github.io/scut
+*/
+
+@mixin scut-clearfix {
+
+ &:after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+
+}
+
+%scut-clearfix {
+ @include scut-clearfix;
+}
+@mixin scut-list-unstyled(
+ $no-margin: true
+) {
+
+ list-style-type: none;
+ padding-left: 0;
+
+ @if $no-margin {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+}
+
+%scut-list-unstyled {
+ @include scut-list-unstyled();
+}
+// Depends on `list-unstyled` and `clearfix`.
+
+@mixin scut-list-floated (
+ $space: false,
+ $dir: left,
+ $no-margin: true
+) {
+
+ @include scut-list-unstyled($no-margin);
+ @include scut-clearfix;
+
+ & > li {
+ float: $dir;
+ }
+
+ @if $space {
+ & > li + li {
+ margin-#{$dir}: $space;
+ }
+ }
+
+}
+
+%scut-list-floated {
+ @include scut-list-floated;
+}
+
+@function scut-autoOrValue ($val) {
+ @if $val == a or $val == auto {
+ @return auto;
+ }
+ @else {
+ @return $val;
+ }
+}
+
+@mixin scut-coords (
+ $coordinates: n n n n
+) {
+
+ $top: nth($coordinates, 1);
+ $right: nth($coordinates, 2);
+ $bottom: nth($coordinates, 3);
+ $left: nth($coordinates, 4);
+
+ @if $top != n {
+ top: scut-autoOrValue($top);
+ }
+ @if $right != n {
+ right: scut-autoOrValue($right);
+ }
+ @if $bottom != n {
+ bottom: scut-autoOrValue($bottom);
+ }
+ @if $left != n {
+ left: scut-autoOrValue($left);
+ }
+
+}
+@function scut-strip-unit (
+ $num
+) {
+
+ @return $num / ($num * 0 + 1);
+
+}
+// Depends on `scut-strip-unit`.
+
+$scut-em-base: 16 !default;
+
+@function scut-em (
+ $pixels,
+ $base: $scut-em-base
+) {
+
+ // $base could be in em or px (no unit = px).
+ // Adjust accordingly to create a $divisor that
+ // serves as context for $pixels.
+ $multiplier: if(unit($base) == em, 16, 1);
+ $divisor: scut-strip-unit($base) * $multiplier;
+
+ $em-vals: ();
+ @each $val in $pixels {
+ $val-in-ems: (scut-strip-unit($val) / $divisor) * 1em;
+ $em-vals: append($em-vals, $val-in-ems);
+ }
+
+ @if length($em-vals) == 1 {
+ // return a single value instead of a list,
+ // so it can be used in calculations
+ @return nth($em-vals, 1);
+ }
+ @else {
+ @return $em-vals;
+ }
+
+}
+// Depends on `scut-strip-unit`.
+
+$scut-rem-base: 16 !default;
+
+@function scut-rem (
+ $pixels
+) {
+
+ $rem-vals: ();
+ @each $val in $pixels {
+ $val-in-rems: scut-strip-unit($val) / $scut-rem-base * 1rem;
+ $rem-vals: append($rem-vals, $val-in-rems);
+ }
+
+ @if length($rem-vals) == 1 {
+ // return a single value instead of a list,
+ // so it can be used in calculations
+ @return nth($rem-vals, 1);
+ }
+ @else {
+ @return $rem-vals;
+ }
+
+}
+@mixin scut-border (
+ $style,
+ $sides: n y
+) {
+
+ @if length($sides) == 2 {
+ @if nth($sides, 1) != n {
+ border-top: $style;
+ border-bottom: $style;
+ }
+ @if nth($sides, 2) != n {
+ border-left: $style;
+ border-right: $style;
+ }
+ }
+
+ @else if length($sides) == 4 {
+ @if nth($sides, 1) != n {
+ border-top: $style;
+ }
+ @if nth($sides, 2) != n {
+ border-right: $style;
+ }
+ @if nth($sides, 3) != n {
+ border-bottom: $style;
+ }
+ @if nth($sides, 4) != n {
+ border-left: $style;
+ }
+ }
+
+ @else {
+ @warn "Scut-border requires a $sides argument of 2 or 4 values."
+ }
+
+}
+@mixin scut-circle (
+ $size,
+ $color: inherit
+) {
+
+ border-radius: 50%;
+ display: inline-block;
+
+ @if $color == inherit {
+ // If user wants to inherit the color,
+ // take advantage of the fact that border
+ // color defaults to the text color of the element.
+ border-width: $size / 2;
+ border-style: solid;
+ height: 0;
+ width: 0;
+ }
+ @else {
+ // Otherwise, just use background-color.
+ background-color: $color;
+ height: $size;
+ width: $size;
+ }
+
+}
+@mixin scut-color-swap (
+ $off,
+ $on,
+ $duration: 0,
+ $bg: false
+) {
+
+ $transition-properties: null;
+ $off-is-list: type-of($off) == list;
+ $on-is-list: type-of($on) == list;
+
+ // If $off IS a list,
+ // assign color and background-color.
+ @if $off-is-list {
+ color: nth($off, 1);
+ background-color: nth($off, 2);
+ $transition-properties: background-color, color;
+ }
+
+ // If $off IS NOT a list and $bg is TRUE,
+ // assign background-color.
+ @else if $bg and not($off-is-list) {
+ background-color: $off;
+ $transition-properties: background-color;
+ }
+
+ // If $off IS NOT a list and $bg is FALSE,
+ // assign color.
+ @else {
+ color: $off;
+ $transition-properties: color;
+ }
+
+ // Only set-up transition if $duration != 0.
+ @if $duration != 0 {
+ transition-property: $transition-properties;
+ transition-duration: $duration;
+ }
+
+ &:hover,
+ &:focus {
+
+ // $on is treated the same as $off, above.
+ @if $on-is-list {
+ color: nth($on, 1);
+ background-color: nth($on, 2);
+ }
+
+ @else if $bg and not($on-is-list) {
+ background-color: $on;
+ }
+
+ @else {
+ color: $on;
+ }
+ }
+
+}
+@mixin scut-hd-bp (
+ $ratio: 1.3
+) {
+
+ @media (-o-min-device-pixel-ratio: ($ratio / 1)),
+ (-webkit-min-device-pixel-ratio: $ratio),
+ (min-resolution: (round(96 * $ratio) * 1dpi)) {
+ @content;
+ }
+
+}
+
+@mixin scut-hide-visually {
+
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+
+}
+
+%scut-hide-visually {
+ @include scut-hide-visually;
+}
+@mixin scut-image-replace {
+
+ text-indent: 102%;
+ white-space: nowrap;
+ overflow: hidden;
+ padding: 0;
+
+}
+
+%scut-image-replace {
+ @include scut-image-replace;
+}
+
+// Depends on scut-rem and scut-strip-unit
+
+@mixin scut-rem-fallback (
+ $pixels,
+ $property: font-size
+) {
+
+ $px-vals: null;
+ @each $val in $pixels {
+ $val-in-px: scut-strip-unit($val) * 1px;
+ $px-vals: append($px-vals, $val-in-px);
+ }
+ $rem-vals: scut-rem($pixels);
+
+ #{$property}: $px-vals;
+ #{$property}: $rem-vals;
+
+}
+@mixin scut-reset-border-box {
+ // Make everything a border-box, because why not?
+ html {
+ box-sizing: border-box;
+ }
+ *, *:before, *:after {
+ box-sizing: inherit;
+ }
+}
+
+@mixin scut-reset-antialias {
+ // Antialias!
+ body {
+ -webkit-font-smoothing: antialiased;
+ }
+ *, *:before, *:after {
+ -webkit-font-smoothing: inherit;
+ }
+}
+
+@mixin scut-reset-semanticize {
+ // Make headers and <b> semantic, not presentational.
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-size: 1em;
+ font-weight: normal;
+ margin: 0;
+ }
+ b {
+ font-weight: normal;
+ }
+}
+
+@mixin scut-reset-pointer {
+ // Clickable form elements should have a pointer.
+ label,
+ select,
+ option,
+ button {
+ cursor: pointer;
+ }
+}
+
+@mixin scut-reset-form {
+ fieldset {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ }
+ textarea {
+ resize: vertical;
+ }
+}
+
+@mixin scut-reset-button {
+ // Reset default button styles, which are never used.
+ button,
+ input[type="button"],
+ input[type="submit"],
+ input[type="reset"] {
+ background: transparent;
+ border: 0;
+ color: inherit;
+ font: inherit;
+ margin: 0;
+ padding: 0;
+ width: auto;
+ -webkit-appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ &::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+ }
+ }
+}
+
+@mixin scut-reset-paragraph {
+ // Some paragraph margins just get in the way.
+ p:first-of-type {
+ margin-top: 0;
+ }
+ p:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+@mixin scut-reset-media {
+ // You want these elements fluid, probably.
+ img,
+ video {
+ max-width: 100%;
+ height: auto;
+ }
+}
+
+@mixin scut-reset-figure {
+ // Remove default margins.
+ figure {
+ margin: 0;
+ }
+}
+
+// Call them all, minus exclusions!
+@mixin scut-reset ($exclude: false) {
+ @if not(index($exclude, border-box)) {
+ @include scut-reset-border-box;
+ }
+ @if not(index($exclude, antialias)) {
+ @include scut-reset-antialias;
+ }
+ @if not(index($exclude, semanticize)) {
+ @include scut-reset-semanticize;
+ }
+ @if not(index($exclude, pointer)) {
+ @include scut-reset-pointer;
+ }
+ @if not(index($exclude, form)) {
+ @include scut-reset-form;
+ }
+ @if not(index($exclude, button)) {
+ @include scut-reset-button;
+ }
+ @if not(index($exclude, paragraph)) {
+ @include scut-reset-paragraph;
+ }
+ @if not(index($exclude, media)) {
+ @include scut-reset-media;
+ }
+ @if not(index($exclude, figure)) {
+ @include scut-reset-figure;
+ }
+}
+
+@mixin scut-selected (
+ $active: false
+) {
+
+ @if $active {
+ &:hover,
+ &:focus,
+ &:active {
+ @content;
+ }
+ }
+ @else {
+ &:hover,
+ &:focus {
+ @content;
+ }
+ }
+
+}
+@mixin scut-triangle (
+ $direction: right,
+ $size: 0.75em,
+ $color: inherit
+) {
+
+ display: inline-block;
+ height: 0;
+ width: 0;
+ // For improved appearance in some Webkit browsers
+ -webkit-transform: rotate(360deg);
+
+ // Set up some variables
+ $width: null;
+ $height: null;
+ $border-widths: null;
+
+ @if type-of($size) == list {
+ $width: nth($size, 1);
+ $height: nth($size, 2);
+ }
+ @else {
+ $width: $size;
+ $height: $size;
+ }
+
+ @if ($direction == up) or ($direction == down) {
+ // For up and down, width gets two borders but height only one,
+ // so divide second border-width value by 2
+ $border-widths: $height ($width / 2);
+ }
+ @else if ($direction == right) or ($direction == left) {
+ // For right and left, height gets two borders but width only one,
+ // so divide first border-width value by 2
+ $border-widths: ($height / 2) $width;
+ }
+ @else {
+ // For right triangles (the rest), both sides get two borders,
+ // so divide both by 2
+ $border-widths: ($height / 2) ($width / 2);
+ }
+
+ border-width: $border-widths;
+ border-style: solid;
+
+
+ // STANDARD TRIANGLES
+
+ @if ($direction == up) or ($direction == down) or ($direction == right) or ($direction == left) {
+ border-color: transparent;
+ @if $direction == up {
+ border-bottom-color: $color;
+ border-top-width: 0;
+ }
+ @else if $direction == right {
+ border-left-color: $color;
+ border-right-width: 0;
+ }
+ @else if $direction == down {
+ border-top-color: $color;
+ border-bottom-width: 0;
+ }
+ @else if $direction == left {
+ border-right-color: $color;
+ border-left-width: 0;
+ }
+ }
+
+
+ // CORNER TRIANGLES
+
+ @else if ($direction == top-right) or ($direction == top-left) {
+ border-top-color: $color;
+ border-bottom-color: transparent;
+ @if $direction == top-right {
+ border-left-color: transparent;
+ border-right-color: $color;
+ }
+ @else if $direction == top-left {
+ border-left-color: $color;
+ border-right-color: transparent;
+ }
+ }
+
+ @else if ($direction == bottom-right) or ($direction == bottom-left) {
+ border-top-color: transparent;
+ border-bottom-color: $color;
+ @if $direction == bottom-right {
+ border-left-color: transparent;
+ border-right-color: $color;
+ }
+ @else if $direction == bottom-left {
+ border-left-color: $color;
+ border-right-color: transparent;
+ }
+ }
+
+}
+
+%scut-triangle {
+ @include scut-triangle;
+}
+@mixin scut-center-absolutely (
+ $dimensions
+) {
+
+ $width: nth($dimensions, 1);
+ $height: nth($dimensions, 2);
+
+ position: absolute;
+
+ @if $width != n {
+ width: $width;
+ left: 50%;
+ margin-left: (-$width / 2);
+ }
+
+ @if $height != n {
+ height: $height;
+ top: 50%;
+ margin-top: (-$height / 2);
+ }
+
+}
+@mixin scut-center-block (
+ $max-width: false
+) {
+
+ margin-left: auto;
+ margin-right: auto;
+ @if $max-width {
+ max-width: $max-width;
+ }
+
+}
+
+%scut-center-block {
+ @include scut-center-block;
+}
+
+@mixin scut-center-transform (
+ $axis: false // or x or y
+) {
+
+ position: absolute;
+
+ @if $axis != x {
+ top: 50%;
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+
+ @if $axis != y {
+ left: 50%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ $translate-val: null;
+
+ @if not($axis) {
+ $translate-val: translate(-50%, -50%);
+ }
+ @else if $axis != x {
+ $translate-val: translateY(-50%);
+ }
+ @else if $axis != y {
+ $translate-val: translateX(-50%);
+ }
+
+ -webkit-transform: $translate-val;
+ -ms-transform: $translate-val;
+ transform: $translate-val;
+}
+
+%scut-center-transform {
+ @include scut-center-transform;
+}
+
+%scut-center-transform-x {
+ @include scut-center-transform(x);
+}
+
+%scut-center-transform-y {
+ @include scut-center-transform(y);
+}
+
+@mixin scut-fill (
+ $width-height: false
+) {
+
+ position: absolute;
+ left: 0;
+ top: 0;
+ @if $width-height {
+ width: 100%;
+ height: 100%;
+ }
+ @else {
+ right: 0;
+ bottom: 0;
+ }
+
+}
+
+%scut-fill {
+ @include scut-fill;
+}
+@mixin scut-list-custom (
+ $content: "\2022",
+ $marker-width: 0.75em,
+ $pad: 0,
+ $no-margin: false
+) {
+
+ $content-val: null;
+ $counter: index($content, count);
+ @if $counter {
+ @if length($content) == 3 {
+ $content-val: counter(scutlistcounter, nth($content, 3))nth($content,2);
+ }
+ @else if length($content) == 2 {
+ $content-val: counter(scutlistcounter)nth($content,2);
+ }
+ @else {
+ $content-val: counter(scutlistcounter);
+ }
+ }
+ @else {
+ $content-val: $content;
+ }
+
+ padding-left: $marker-width + $pad;
+ list-style-type: none;
+
+ @if $no-margin {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+ & > li {
+ position: relative;
+ @if $counter {
+ counter-increment: scutlistcounter;
+ }
+ &:before {
+ content: $content-val;
+ display: block;
+ position: absolute;
+ top: 0;
+ left: -$marker-width;
+ width: $marker-width;
+ @content;
+ }
+ }
+
+}
+// Depends on `list-floated`, which depends in turn on `list-unstyled` and `clearfix`.
+
+@mixin scut-list-divided (
+ $divider: "|",
+ $space: 0.5em,
+ $dir: left,
+ $height: false,
+ $no-margin: true
+) {
+
+ @include scut-list-floated($dir: $dir, $no-margin: $no-margin);
+
+ $pseudo: if($dir == left, 'before', 'after');
+
+ // If an explicit height is passed,
+ // things are different: All <li>s
+ // need the pseudo-element (to force height),
+ // but the first's must be hidden.
+
+ @if $height {
+ & > li {
+ height: $height;
+ }
+ & > li:#{$pseudo} {
+ height: $height;
+ content: $divider;
+ display: inline-block;
+ vertical-align: middle;
+ @content;
+ }
+ & > li:first-child:#{$pseudo} {
+ width: 0;
+ overflow: hidden;
+ }
+ }
+
+ & > li + li:#{$pseudo} {
+ @if not($height) {
+ content: $divider;
+ display: inline-block;
+ @content;
+ }
+ margin-left: $space;
+ margin-right: $space;
+ }
+
+}
+
+%scut-list-bar {
+ @include scut-list-divided;
+}
+
+%scut-list-breadcrumb {
+ @include scut-list-divided("/");
+}
+// Depends on `list-unstyled`.
+
+@mixin scut-list-inline (
+ $space: false,
+ $no-margin: true
+) {
+
+ @include scut-list-unstyled($no-margin);
+
+ & > li {
+ display: inline-block;
+ }
+
+ @if $space {
+ & > li + li {
+ margin-left: $space;
+ }
+ }
+
+}
+
+%scut-list-inline {
+ @include scut-list-inline;
+}
+// Depends on `list-unstyled`.
+
+@mixin scut-list-punctuated (
+ $divider: ", ",
+ $display: inline,
+ $no-margin: true
+) {
+
+ @include scut-list-unstyled($no-margin);
+
+ & > li {
+ display: $display;
+ &:not(:last-child):after {
+ content: $divider;
+ }
+ }
+
+}
+
+%scut-list-comma {
+ @include scut-list-punctuated;
+}
+@mixin scut-margin (
+ $margin
+) {
+
+ @if length($margin) == 1 and $margin != n {
+ margin-top: $margin;
+ margin-right: $margin;
+ margin-bottom: $margin;
+ margin-left: $margin;
+ }
+
+ @if length($margin) == 2 {
+ $margin-y: nth($margin, 1);
+ $margin-x: nth($margin, 2);
+ @if $margin-y != n {
+ margin-top: $margin-y;
+ margin-bottom: $margin-y;
+ }
+ @if $margin-x != n {
+ margin-left: $margin-x;
+ margin-right: $margin-x;
+ }
+ }
+
+ @if length($margin) == 3 {
+ $margin-y-top: nth($margin, 1);
+ $margin-x: nth($margin, 2);
+ $margin-y-bottom: nth($margin, 3);
+ @if $margin-y-top != n {
+ margin-top: $margin-y-top;
+ }
+ @if $margin-x != n {
+ margin-right: $margin-x;
+ margin-left: $margin-x;
+ }
+ @if $margin-y-bottom != n {
+ margin-bottom: $margin-y-bottom;
+ }
+ }
+
+ @if length($margin) == 4 {
+ $margin-top: nth($margin, 1);
+ $margin-right: nth($margin, 2);
+ $margin-bottom: nth($margin, 3);
+ $margin-left: nth($margin, 4);
+ @if $margin-top != n {
+ margin-top: $margin-top;
+ }
+ @if $margin-right != n {
+ margin-right: $margin-right;
+ }
+ @if $margin-bottom != n {
+ margin-bottom: $margin-bottom;
+ }
+ @if $margin-left != n {
+ margin-left: $margin-left;
+ }
+ }
+
+}
+@mixin scut-padding (
+ $padding
+) {
+
+ @if length($padding) == 1 and $padding != n {
+ padding-top: $padding;
+ padding-right: $padding;
+ padding-bottom: $padding;
+ padding-left: $padding;
+ }
+
+ @if length($padding) == 2 {
+ $padding-y: nth($padding, 1);
+ $padding-x: nth($padding, 2);
+ @if $padding-y != n {
+ padding-top: $padding-y;
+ padding-bottom: $padding-y;
+ }
+ @if $padding-x != n {
+ padding-left: $padding-x;
+ padding-right: $padding-x;
+ }
+ }
+
+ @if length($padding) == 3 {
+ $padding-y-top: nth($padding, 1);
+ $padding-x: nth($padding, 2);
+ $padding-y-bottom: nth($padding, 3);
+ @if $padding-y-top != n {
+ padding-top: $padding-y-top;
+ }
+ @if $padding-x != n {
+ padding-right: $padding-x;
+ padding-left: $padding-x;
+ }
+ @if $padding-y-bottom != n {
+ padding-bottom: $padding-y-bottom;
+ }
+ }
+
+ @if length($padding) == 4 {
+ $padding-top: nth($padding, 1);
+ $padding-right: nth($padding, 2);
+ $padding-bottom: nth($padding, 3);
+ $padding-left: nth($padding, 4);
+ @if $padding-top != n {
+ padding-top: $padding-top;
+ }
+ @if $padding-right != n {
+ padding-right: $padding-right;
+ }
+ @if $padding-bottom != n {
+ padding-bottom: $padding-bottom;
+ }
+ @if $padding-left != n {
+ padding-left: $padding-left;
+ }
+ }
+}
+// Depends on `positioning-coordinates`.
+
+@mixin scut-absolute (
+ $coordinates: 0 n n 0
+) {
+
+ position: absolute;
+ @include scut-coords($coordinates);
+
+}
+
+%scut-absolute {
+ @include scut-absolute;
+}
+// Depends on `positioning-coordinates`.
+
+@mixin scut-fixed (
+ $coordinates: 0 n n 0
+) {
+
+ position: fixed;
+ @include scut-coords($coordinates);
+
+}
+
+%scut-fixed {
+ @include scut-fixed;
+}
+// Depends on `positioning-coordinates`.
+
+@mixin scut-relative (
+ $coordinates: n n n n
+) {
+
+ position: relative;
+ @include scut-coords($coordinates);
+
+}
+@mixin scut-ratio-box (
+ $ratio: 1/1
+) {
+
+ overflow: hidden;
+ position: relative;
+
+ // The container's height, as a percentage of the
+ // container's width, is set by assigning
+ // padding-top to a pseudo-element.
+ &:before {
+ content: "";
+ display: block;
+ height: 0;
+ padding-top: (1 / $ratio) * 100%;
+ }
+
+}
+
+%scut-ratio-box {
+ @include scut-ratio-box;
+}
+@mixin scut-size(
+ $size
+) {
+
+ @if length($size) == 1 {
+ width: $size;
+ height: $size;
+ }
+ @else if length($size) == 2 {
+ width: nth($size, 1);
+ height: nth($size, 2);
+ }
+
+}
+@mixin scut-sticky-footer-fixed (
+ $height,
+ $wrapper: ".wrapper",
+ $footer: ".scut-sticky"
+) {
+
+ html,
+ body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ }
+
+ #{$wrapper} {
+ min-height: 100%;
+ margin-bottom: -$height;
+ &:after {
+ content: "";
+ display: block;
+ }
+ }
+
+ #{$wrapper}:after,
+ #{$footer} {
+ height: $height;
+ }
+
+}
+
+// deprecated
+@mixin scut-sticky-footer (
+ $height,
+ $wrapper: ".wrapper",
+ $footer: ".scut-sticky"
+){
+ @include scut-sticky-footer-fixed($height, $wrapper, $footer);
+}
+@mixin scut-sticky-footer-fluid (
+ $wrapper: ".wrapper",
+ $footer: ".scut-sticky"
+) {
+
+ html,
+ body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ }
+
+ #{$wrapper} {
+ display: table;
+ height: 100%;
+ width: 100%;
+ }
+
+ #{$footer} {
+ display: table-row;
+ height: 1px;
+ }
+
+}
+@mixin scut-vcenter-ib (
+ $inner...
+) {
+
+ // The inner element is vertically centered
+ // by middle-aligning it with an inline pseudo-element
+ // whose height is 100%.
+
+ &:before {
+ content: "";
+ height: 100%;
+ display: inline-block;
+ vertical-align: middle;
+ // A small negative right margin is set
+ // to account for the default
+ // word-spacing of inline-block.
+ margin-right: -0.25em;
+ }
+
+ $inner: if(length($inner) == 0, ".scut-inner", $inner);
+ @each $cell-selector in $inner {
+ $cell-selector: unquote($cell-selector);
+ & > #{$cell-selector} {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+
+}
+
+%scut-vcenter-ib {
+ @include scut-vcenter-ib;
+}
+
+@mixin scut-vcenter-lh (
+ $height
+) {
+
+ height: $height;
+ line-height: $height;
+
+}
+@mixin scut-vcenter-td (
+ $inner...
+) {
+
+ display: table;
+
+ $inner: if(length($inner) == 0, ".scut-inner", $inner);
+ @each $cell-selector in $inner {
+ $cell-selector: unquote($cell-selector);
+ & > #{$cell-selector} {
+ display: table-cell;
+ vertical-align: middle;
+ }
+ }
+
+}
+
+
+%scut-vcenter-td {
+ @include scut-vcenter-td;
+}
+
+// Depends on scut-center-transform
+
+@mixin scut-vcenter-tt () {
+ @include scut-center-transform(y);
+}
+
+%scut-vcenter-tt {
+ @include scut-vcenter-tt;
+}
+// space
+$scut-space: "\0020";
+// non-breaking space
+$scut-nbsp: "\00a0";
+
+// quotation mark
+$scut-quot: "\0022";
+// left single curly quote
+$scut-lsquo: "\2018";
+// right single curly quote
+$scut-rsquo: "\2019";
+// left double curly quote
+$scut-ldquo: "\201C";
+// right double curly quote
+$scut-rdquo: "\201D";
+// left single angle quote (guillemet)
+$scut-lsaquo: "\2039";
+// right single angle quote (guillemet)
+$scut-rsaquo: "\203A";
+// left double angle quote (guillemet)
+$scut-laquo: "\00ab";
+// right double angle quote (guillemet)
+$scut-raquo: "\00bb";
+
+// em dash (mutton)
+$scut-mdash: "\2014";
+// en dash (nut)
+$scut-ndash: "\2013";
+// hyphen
+$scut-hyphen: "\2010";
+
+// ampersand
+$scut-amp: "\0026";
+// greater than
+$scut-gt: "\003e";
+// less than
+$scut-lt: "\003c";
+// times
+$scut-times: "\00D7";
+// big times
+$scut-bigtimes: "\2715";
+// checkmark
+$scut-checkmark: "\2713";
+
+// section sign (double S, hurricane, sectional symbol, the legal doughnut, signum sectionis)
+$scut-sect: "\00a7";
+// paragraph symbol (pilcrow)
+$scut-para: "\00b6";
+
+// middot (interpunct, interpoint)
+$scut-middot: "\00b7";
+// o-slash (slashed o)
+$scut-oslash: "\00f8";
+// bullet
+$scut-bull: "\2022";
+// white bullet
+$scut-whibull: "\25E6";
+// horizontal ellipsis
+$scut-hellip: "\2026";
+// vertical ellipsis
+$scut-vellip: "\22EE";
+// midline horizontal ellipsis
+$scut-midhellip: "\22EF";
+
+// up-pointing triangle
+$scut-utri: "\25b2";
+// down-pointing triangle
+$scut-dtri: "\25bc";
+// left-pointing triangle
+$scut-ltri: "\25c0";
+// right-pointing triangle
+$scut-rtri: "\25b6";
+// up-pointing small triangle
+$scut-ustri: "\25b4";
+// down-pointing small triangle
+$scut-dstri: "\25be";
+// left-pointing small triangle
+$scut-lstri: "\25c2";
+// right-pointing small triangle
+$scut-rstri: "\25b8";
+// diamond
+$scut-diamond: "\25c6";
+// fisheye
+$scut-fisheye: "\25c9";
+// bullseye
+$scut-bullseye: "\25ce";
+// circle
+$scut-circle: "\25cf";
+// white circle
+$scut-whitecircle: "\25cb";
+// square
+$scut-square: "\25a0";
+// white square
+$scut-whitesquare: "\25a1";
+// small square
+$scut-ssquare: "\25aa";
+// small white square
+$scut-swhitesquare: "\25ab";
+@function main-src($formats, $file-path, $font-family) {
+ // Return the list of `src` values, in order, that
+ // a good `@font-face` will need, including only
+ // those formats specified in the list `$formats`.
+ $result: ();
+ @if index($formats, eot) {
+ $eot-val: url('#{$file-path}.eot?#iefix') format('embedded-opentype');
+ $result: append($result, $eot-val, comma);
+ }
+ @if index($formats, woff2) {
+ $woff2-val: url('#{$file-path}.woff2') format('woff2');
+ $result: append($result, $woff2-val, comma);
+ }
+ @if index($formats, woff) {
+ $woff-val: url('#{$file-path}.woff') format('woff');
+ $result: append($result, $woff-val, comma);
+ }
+ @if index($formats, ttf) {
+ $ttf-val: url('#{$file-path}.ttf') format('truetype');
+ $result: append($result, $ttf-val, comma);
+ }
+ @if index($formats, svg) {
+ $svg-val: url('#{$file-path}.svg##{$font-family}') format('svg');
+ $result: append($result, $svg-val, comma);
+ }
+ @return $result;
+}
+
+@mixin scut-font-face (
+ $font-family,
+ $file-path,
+ $weight: normal,
+ $style: normal,
+ $formats: eot woff2 woff ttf svg
+) {
+
+ @if index('italic' 'oblique', $weight) {
+ $style: $weight;
+ $weight: normal;
+ }
+
+ @font-face {
+ font-family: $font-family;
+ font-weight: $weight;
+ font-style: $style;
+
+ @if index($formats, eot) {
+ src: url('#{$file-path}.eot');
+ }
+ src: main-src($formats, $file-path, $font-family);
+ }
+
+}
+
+@mixin scut-hanging-indent (
+ $indent: 1em
+) {
+
+ // padding-left creates the indent,
+ // while text-indent pulls the first line
+ // back to the edge.
+
+ padding-left: $indent;
+ text-indent: -$indent;
+
+}
+
+%scut-hanging-indent {
+ @include scut-hanging-indent;
+}
+@mixin scut-indented-ps (
+ $indent: 1.5em,
+ $no-first-indent: true
+) {
+
+ p {
+ margin: 0;
+ text-indent: $indent;
+ }
+
+ @if $no-first-indent {
+ p:first-of-type {
+ text-indent: 0;
+ }
+ }
+
+}
+
+%scut-indented-ps {
+ @include scut-indented-ps;
+}
+@mixin scut-key-val (
+ $divider: ":",
+ $pad: 0.25em,
+ $indent: 1em,
+ $spacing: 0,
+ $pad-left: 0
+) {
+
+ & > dt {
+ clear: both;
+ float: left;
+ &:after {
+ content: $divider;
+ margin-right: $pad;
+ @if $pad-left != 0 {
+ margin-left: $pad-left;
+ }
+ }
+ }
+
+ & > dd {
+ margin-left: $indent;
+ @if $spacing != 0 {
+ margin-bottom: $spacing;
+ }
+ }
+
+}
+
+%scut-key-val {
+ @include scut-key-val;
+}
+@mixin scut-link-bb (
+ $color: inherit,
+ $style: solid,
+ $width: 1px
+) {
+
+ text-decoration: none;
+
+ border-bottom-width: $width;
+ border-bottom-style: $style;
+ @if $color != inherit {
+ border-bottom-color: $color;
+ }
+
+}
+
+%scut-link-bb {
+ @include scut-link-bb;
+}
+// SCUT LINK UNSTYLED
+// http://davidtheclark.github.io/scut/#link-unstyled
+
+@mixin scut-link-unstyled() {
+
+ text-decoration: none;
+ color: inherit;
+
+}
+
+%scut-link-unstyled {
+ @include scut-link-unstyled();
+}
+
+@mixin scut-reverse-italics (
+ $elements: null
+) {
+
+ $element-list: em, cite, i;
+ font-style: italic;
+ #{join($element-list, $elements)} {
+ font-style: normal;
+ }
+
+}
+
+%scut-reverse-italics {
+ @include scut-reverse-italics;
+}
+
+@mixin scut-side-lined (
+ $height: 1px,
+ $space: 0.5em,
+ $color: inherit,
+ $style: solid,
+ $v-adjust: false,
+ $double: false
+) {
+
+ display: block;
+ overflow: hidden;
+ text-align: center;
+
+ &:before,
+ &:after {
+ content: "";
+ display: inline-block;
+ vertical-align: middle;
+ position: relative;
+ width: 50%;
+
+ border-top-style: $style;
+ border-top-width: $height;
+
+ @if $color != inherit {
+ border-top-color: $color;
+ }
+
+ @if $v-adjust != false {
+ bottom: $v-adjust;
+ }
+
+ @if $double != false {
+ height: $double;
+ border-bottom-style: $style;
+ border-bottom-width: $height;
+ @if $color != inherit {
+ border-bottom-color: $color;
+ }
+ }
+ }
+
+ &:before {
+ right: $space;
+ margin-left: -50%;
+ }
+ &:after {
+ left: $space;
+ margin-right: -50%;
+ }
+
+}
+
+%scut-side-lined {
+ @include scut-side-lined;
+}
+@mixin scut-truncate {
+
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+}
+
+%scut-truncate {
+ @include scut-truncate;
+} \ No newline at end of file
diff --git a/web-ui/app/scss/views/_close-button.scss b/web-ui/app/scss/views/_close-button.scss
new file mode 100644
index 00000000..37171c18
--- /dev/null
+++ b/web-ui/app/scss/views/_close-button.scss
@@ -0,0 +1,22 @@
+.close-mail-button {
+ $button-size: 27px;
+
+ margin-right: 3px;
+ float: left;
+ background: $lighter_gray;
+ color: $medium_light_grey;
+ width: $button-size;
+ height: $button-size;
+ padding: 0;
+ border-radius: 0;
+
+ &:hover, &:focus, &:active {
+ background-color: darken($lighter_gray, 2);
+ color: darken($medium_light_grey, 10);
+ }
+
+ i {
+ padding: 0;
+ margin: 0;
+ }
+}
diff --git a/web-ui/app/scss/views/_compose-button.scss b/web-ui/app/scss/views/_compose-button.scss
new file mode 100644
index 00000000..41814a38
--- /dev/null
+++ b/web-ui/app/scss/views/_compose-button.scss
@@ -0,0 +1,24 @@
+// COMPOSE BUTTON
+#compose {
+ margin-bottom: 5px;
+ padding-right: 4px;
+ #compose-trigger {
+ width: 100%;
+ display: inline-block;
+ padding: 5px;
+ #compose-mails-trigger {
+ background: $action_buttons;
+ color: $white;
+ padding: 10px 30px;
+ text-align: center;
+ font-weight: 400;
+ font-size: 1.2em;
+ @include btn-transition;
+ &:hover {
+ background: lighten($action_buttons, 10%);
+ cursor: pointer;
+ }
+ }
+ }
+}
+
diff --git a/web-ui/app/scss/views/_compose-view.scss b/web-ui/app/scss/views/_compose-view.scss
new file mode 100644
index 00000000..2ed305c8
--- /dev/null
+++ b/web-ui/app/scss/views/_compose-view.scss
@@ -0,0 +1,320 @@
+.compose-view {
+ overflow: auto;
+}
+
+// COMPOSE PANE
+#compose-box, #draft-box, #reply-box, #feedback-box {
+ div.floatlabel {
+ position: relative;
+ }
+
+ .input-container {
+ padding: 1px;
+ }
+
+ label, span {
+ color: $recipients_font_color;
+ padding: 0.5rem;
+ cursor: text;
+ display: inline-block;
+ }
+
+ label {
+ padding: 13px 10px;
+ }
+
+ span {
+ padding: 3px;
+
+ &.attachment-size {
+ color: $attachment_size;
+ cursor: pointer;
+ }
+ }
+
+ label.floatlabel {
+ padding: 0.4rem !important;
+ position: absolute;
+ font-size: 0.6rem;
+ transition: all 0.1s linear;
+ opacity: 0;
+ font-weight: bold;
+ }
+
+ label.showfloatlabel {
+ color: $light_blue !important;
+ top: -0.3rem;
+ opacity: 1;
+ }
+
+ input, textarea {
+ margin: 0;
+ border: none;
+ transition: all 0.1s linear;
+ }
+
+ input.showfloatlabel, textarea.showfloatlabel {
+ padding-top: 1rem !important;
+ }
+
+ input#subject, #feedback-subject {
+ font-size: 1.6875rem;
+ line-height: 1.4;
+ border-top: 1px solid $lighter_gray;
+ }
+
+ #feedback-subject {
+ color: $dark_grey;
+ }
+
+ textarea {
+ border-bottom: 2px solid $lighter_gray;
+ min-height: 400px;
+ font-family: inherit;
+ font-weight: normal;
+ font-size: 1rem;
+ line-height: 1.6;
+ text-rendering: optimizeLegibility;
+ }
+
+ &.reply-box, &.forward-box {
+ margin: 0;
+
+ h4 {
+ font-size: 0.9em;
+ font-style: italic;
+ color: $medium_grey;
+ margin: 2px 0;
+ clear: both;
+ cursor: pointer;
+
+ &:hover {
+ background: $contrast;
+ }
+ }
+
+ textarea {
+ min-height: 200px;
+ margin: 10px 0;
+ }
+
+ p {
+ padding: 5px;
+ margin: 10px 0;
+ font-style: italic;
+ cursor: pointer;
+
+ &:hover {
+ background: $contrast;
+ }
+ }
+ }
+
+ button.close-mail-button {
+ margin: 1px;
+ }
+
+ .buttons-group {
+ margin-top: 0px;
+ }
+
+ #attachment-upload-item {
+ display: none;
+
+ .progress {
+ width: 0%;
+ position: absolute;
+ right: 0;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ min-height: 100%;
+
+ .progress-bar {
+ height: 100%;
+ background-color: rgba($light_blue, 0.3);
+ }
+ }
+ }
+
+ .attachmentsAreaWrap {
+ padding: 0;
+
+ .attachmentsArea {
+ padding: 0;
+ border-top: 0;
+
+ #upload-error {
+ color: $error;
+ margin-bottom: 20px;
+
+ .close-icon {
+ font-size: 1.0rem;
+ cursor: pointer;
+ }
+
+ span, a {
+ color: $error;
+ font-size: 0.9rem;
+ }
+
+ a {
+ text-decoration: underline;
+ padding: 5px;
+ }
+ }
+ }
+ }
+
+ .recipients-area {
+ -webkit-appearance: none;
+ background-color: white;
+ font-family: inherit;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.898em;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ position: relative;
+
+ .compose-column-label {
+ width: 5%;
+ display: inline-block;
+ }
+
+ .compose-column-recipients {
+ width: 95%;
+ display: inline-block;
+ }
+
+ .recipients-label {
+ width: 100%;
+ height: 100%;
+ }
+
+ .recipients-navigation-handler {
+ z-index: -1;
+ position: absolute;
+ top: -200px;
+ }
+
+ .twitter-typeahead {
+ flex: 1 1 50px;
+ }
+
+ .invalid-format {
+ border-bottom: 1px dotted $error;
+ }
+
+ input[type=text] {
+ vertical-align: top;
+ height: 35px;
+ margin-left: 1px;
+ font-size: 0.9em;
+ width: 100%;
+ }
+
+ .fixed-recipient {
+ display: inline-block;
+ margin-right: -3px;
+ flex: none;
+ position: relative;
+
+ .recipient-value {
+ &.selected {
+ border: 1px solid $medium_dark_grey;
+ }
+
+ &:before {
+ font-family: FontAwesome;
+ padding-right: 4px;
+ }
+
+ &.encrypted {
+ border-bottom-color: $will_be_encrypted;
+
+ &:before {
+ color: $will_be_encrypted;
+ content: "\f023 ";
+ }
+ }
+
+ &.not-encrypted {
+ border-bottom-color: $wont_be_encrypted;
+
+ &:before {
+ color: $wont_be_encrypted;
+ content: "\f13e ";
+ }
+ }
+
+ &.deleting span {
+ text-decoration: line-through;
+ }
+
+ & span {
+ margin: 0px;
+ padding: 0px;
+ cursor: pointer;
+ }
+
+ margin: 3px;
+ padding: 5px;
+ background-color: $background_light_grey;
+ border: 1px solid $border_light_grey;
+ border-radius: 2px;
+ }
+
+ .recipient-del {
+ position: relative;
+ color: $recipients_font_color;
+
+ &:hover, &:focus {
+ color: $recipients_font_color;
+ }
+
+ &:before {
+ margin-left: 0.4em;
+ font-weight: bold;
+ content: "x";
+ }
+
+ &.deleteTooltip:hover:after {
+ position: absolute;
+ content: "Click to remove";
+ font-size: 0.5rem;
+
+ @include tooltip(25px, 0px);
+ }
+ }
+ }
+
+ input.recipients-input:focus {
+ background-color: $dark_white !important;
+ border-color: $medium_light_grey;
+ outline: none;
+ width: 270px;
+ }
+ }
+
+ .collapse {
+ display: block;
+ position: absolute;
+ right: 10px;
+ padding-right: 15px;
+ padding-left: 15px;
+ font-family: 'FontAwesome';
+ font-weight: bolder;
+ font-size: larger;
+ cursor: pointer;
+ }
+
+ .collapse + input, .collapse + input + * {
+ display: none;
+ }
+
+ .collapse + input:checked + * {
+ display: block;
+ }
+}
diff --git a/web-ui/app/scss/views/_message-panel.scss b/web-ui/app/scss/views/_message-panel.scss
new file mode 100644
index 00000000..4a0a7a6b
--- /dev/null
+++ b/web-ui/app/scss/views/_message-panel.scss
@@ -0,0 +1,26 @@
+.message-panel {
+ width: 100%;
+ margin: 10px auto;
+ position: fixed;
+ z-index: 10000;
+ text-align: center;
+
+ &__growl {
+ padding: 5px 60px;
+
+ &--success {
+ background: $warning;
+ color: darken($warning, 50%);
+ border: 1px solid darken($warning, 10%);
+ @include box-shadow(1px 1px 3px darken($warning, 60%));
+ }
+
+ &--error {
+ font-weight: bold;
+ color: white;
+ background: $error;
+ border: 1px solid darken($error, 10%);
+ @include box-shadow(1px 1px 3px darken($error, 60%));
+ }
+ }
+}
diff --git a/web-ui/app/scss/views/_no-mails-available.scss b/web-ui/app/scss/views/_no-mails-available.scss
new file mode 100644
index 00000000..bf5d256a
--- /dev/null
+++ b/web-ui/app/scss/views/_no-mails-available.scss
@@ -0,0 +1,3 @@
+.no-mails-available-pane {
+ @extend .no-content-placeholder;
+}
diff --git a/web-ui/app/scss/views/_no-message-selected.scss b/web-ui/app/scss/views/_no-message-selected.scss
new file mode 100644
index 00000000..0e367bf2
--- /dev/null
+++ b/web-ui/app/scss/views/_no-message-selected.scss
@@ -0,0 +1,14 @@
+.no-message-selected-pane {
+ background: $contrast;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ &__text {
+ @extend .no-content-placeholder;
+
+ margin-bottom: 40px; // aligns label with "no results for XYZ"
+ }
+}
diff --git a/web-ui/app/scss/views/_read-view.scss b/web-ui/app/scss/views/_read-view.scss
new file mode 100644
index 00000000..6c229f9b
--- /dev/null
+++ b/web-ui/app/scss/views/_read-view.scss
@@ -0,0 +1,181 @@
+.mail-read-view {
+ $component-vertical-spacing: 10px;
+ $view-top-spacing: 3px;
+
+ // NB! Setting overflow: hidden on an element causes
+ // a new float context to be created, so elements that
+ // are floated inside an element that has overflow: hidden
+ // applied are cleared.
+ overflow-x: hidden;
+
+ hr {
+ margin: 0;
+ }
+
+ &__header {
+ @include scut-clearfix;
+
+ font-size: 0.9em;
+ margin: 0;
+ margin: $view-top-spacing 0 $component-vertical-spacing 0;
+
+ &-recipients {
+ display: inline;
+ margin-bottom: 5px;
+ line-height: 1.5em;
+
+ &-separator {
+ margin: 0 10px;
+ }
+
+ &--highlight-sender {
+ font-weight: bold;
+ }
+ }
+
+ &-date {
+ display: inline;
+ float: right;
+ }
+
+ &-subject {
+ display: inline;
+ float: left;
+ max-width: 80%;
+ }
+
+ &-actions {
+ display: inline;
+ float: right;
+ max-width: 20%;
+ background: $white;
+ white-space: nowrap;
+ margin-top: $component-vertical-spacing;
+
+ &-button {
+ color: $medium_light_grey;
+ background-color: inherit;
+ display: inline;
+ border: 1px solid $lighter_gray;
+ line-height: 2em;
+
+ margin-bottom: 0;
+
+ i {
+ // workaround: remove padding and margin inserted by font-awesome
+ margin: 0;
+ padding: 0;
+ }
+
+ &:hover, &:active, &:focus {
+ @include btn-transition;
+
+ background: darken($contrast, 5%);
+ color: inherit;
+ }
+
+ &--reply {
+ padding: 0 20px;
+ margin-right: -4px; // force buttons together
+
+ }
+
+ &--more {
+ padding: 0 5px;
+ }
+ }
+
+ &-dropdown {
+ $container-right-padding: 10px;
+
+ background: inherit;
+ position: absolute;
+ border: 1px solid $lighter_gray;
+ right: $container-right-padding;
+
+ &-entry {
+ box-sizing: border-box;
+ background: inherit;
+ padding: 5px 10px;
+ display: block;
+ border-bottom: 1px solid $lighter_gray;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ cursor: pointer;
+ background: $contrast;
+ }
+ }
+ }
+ }
+
+ &-tags {
+ clear: both;
+ margin: 0 0 10px;
+
+ // TODO refactor
+
+ @include tags;
+
+ ul li {
+ &.tag:hover {
+ &:before {
+ content: "click to remove";
+ text-transform: lowercase;
+ font-size: 0.5rem;
+
+ @include tooltip(18px, 8px);
+ }
+ }
+ }
+ }
+ }
+
+ &__body {
+ margin: $component-vertical-spacing 0;
+ width: 100%;
+ border: none;
+ }
+
+ &__attachments {
+ margin: $component-vertical-spacing 0;
+
+ &-header {
+ font-weight: bold;
+ }
+
+ &-item {
+ @include scut-clearfix;
+
+ display: block;
+ margin-bottom: 8px;
+ padding: 5px;
+ border: 1px solid $border_light_grey;
+ border-radius: 2px;
+ background-color: $background_light_grey;
+
+ &-label {
+ color: $attachment_text;
+ text-decoration: none;
+
+ &:hover, &:focus {
+ i.download-icon {
+ color: lighten($attachment_icon, 15);
+ }
+
+ color: $attachment_icon;
+ outline: none;
+ }
+ }
+
+ &-download {
+ color: #a2a2a2;
+ float: right;
+ margin-top: 5px;
+ }
+ }
+ }
+}
diff --git a/web-ui/app/templates/compose/compose_box.hbs b/web-ui/app/templates/compose/compose_box.hbs
index 78447ec2..b88138b5 100644
--- a/web-ui/app/templates/compose/compose_box.hbs
+++ b/web-ui/app/templates/compose/compose_box.hbs
@@ -1,30 +1,32 @@
-{{> recipients }}
-
-<div class="clearfix">
- <a id="to-trigger" class="hide">{{t 'To'}}</a>
- <a id="ccs-trigger" class="hide">{{t 'CC'}}</a>
- <a id="bccs-trigger" class="hide">{{t 'BCC'}}</a>
-</div>
-
<button class="close-mail-button">
<i class="fa fa-times"></i>
</button>
-<div class="floatlabel">
- <label class="floatlabel" for="subject">Subject</label>
- <input class="floatlabel" name="subject" type="text" id="subject" value="{{subject}}" placeholder="{{t 'Subject'}}"
- tabindex="4"/>
-</div>
-<div class="floatlabel">
- <label class="floatlabel" for="body">Body</label>
- <textarea class="floatlabel" name="body" id="text-box" placeholder="{{t 'Body'}}" tabindex="5">{{body}}</textarea>
-</div>
-{{> attachments_list }}
+<div class="compose-view">
+
+ {{> recipients }}
+ <div class="clearfix">
+ <a id="to-trigger" class="hide">{{t 'To'}}</a>
+ <a id="ccs-trigger" class="hide">{{t 'CC'}}</a>
+ <a id="bccs-trigger" class="hide">{{t 'BCC'}}</a>
+ </div>
+
+ <div class="floatlabel">
+ <label class="floatlabel" for="subject">Subject</label>
+ <input class="floatlabel" name="subject" type="text" id="subject" value="{{subject}}" placeholder="{{t 'Subject'}}" tabindex="4"/>
+ </div>
+ <div class="floatlabel">
+ <label class="floatlabel" for="body">Body</label>
+ <textarea class="floatlabel" name="body" id="text-box" placeholder="{{t 'Body'}}" tabindex="5">{{body}}</textarea>
+ </div>
+
+ {{> attachments_list }}
-<div class="buttons-group columns">
- <button id="send-button" tabindex="6"><i class="fa fa-send"></i></button>
- <span id="attachment-button" tabindex="6"></span>
- <button id="trash-button" tabindex="7">{{t 'trash-button'}}<i class="fa fa-trash-o"></i></button>
- <div id="draft-save-status"></div>
-</div>
+ <div class="buttons-group columns">
+ <button id="send-button" tabindex="6"><i class="fa fa-send"></i></button>
+ <span id="attachment-button" tabindex="6"></span>
+ <button id="trash-button" tabindex="7">{{t 'trash-button'}}<i class="fa fa-trash-o"></i></button>
+ <div id="draft-save-status"></div>
+ </div>
+</div> <!-- ./compose-view -->
diff --git a/web-ui/app/templates/compose/no_mails_available.hbs b/web-ui/app/templates/compose/no_mails_available.hbs
index 6388d7db..147f3533 100644
--- a/web-ui/app/templates/compose/no_mails_available.hbs
+++ b/web-ui/app/templates/compose/no_mails_available.hbs
@@ -1,7 +1,7 @@
-<div class="scene">
+<div class="no-mails-available-pane">
{{#if forSearch }}
- <div class="text">{{t 'NO RESULTS FOR'}}: '{{ forSearch }}'.</div>
+ {{t 'NO RESULTS FOR'}}: '{{ forSearch }}'.
{{else}}
- <div class="text">{{t 'NO EMAILS IN'}} '{{ tag }}'.</div>
+ {{t 'NO EMAILS IN'}} '{{ tag }}'.
{{/if}}
</div>
diff --git a/web-ui/app/templates/compose/no_message_selected.hbs b/web-ui/app/templates/compose/no_message_selected.hbs
index 0442192d..71aa6267 100644
--- a/web-ui/app/templates/compose/no_message_selected.hbs
+++ b/web-ui/app/templates/compose/no_message_selected.hbs
@@ -1,3 +1,3 @@
-<div class="scene">
- <div class="text">{{t 'NOTHING SELECTED'}}.</div>
+<div class="no-message-selected-pane">
+ <div class="no-message-selected-pane__text">{{t 'NOTHING SELECTED'}}.</div>
</div>
diff --git a/web-ui/app/templates/mails/full_view.hbs b/web-ui/app/templates/mails/full_view.hbs
index f9ec084a..0bc9d999 100644
--- a/web-ui/app/templates/mails/full_view.hbs
+++ b/web-ui/app/templates/mails/full_view.hbs
@@ -1,29 +1,25 @@
+<button class="close-mail-button">
+ <i class="fa fa-times"></i>
+</button>
-<div id="fullView-{{ ident }}" class="{{statuses}}">
-
- <header class="msg-header row">
-
- <button class="close-mail-button">
- <i class="fa fa-times"></i>
- </button>
-
-
- <div class="full-view-header">
+<div id="fullView-{{ ident }}" class="mail-read-view {{statuses}}">
+ <header class="mail-read-view__header row">
+ <!-- TODO -->
<div class="column large-12 no-padding security-status">
{{#if signatureStatus}}
<span class="{{signatureStatus}}">
{{t signatureStatus }}
</span>
- {{/if}}
- {{#if encryptionStatus}}
+ {{/if}} {{#if encryptionStatus}}
<span class="{{encryptionStatus}}">
{{t encryptionStatus }}
</span>
{{/if}}
</div>
- <div class="recipients column large-10 no-padding">
- <span class="from">
+
+ <div class="mail-read-view__header-recipients">
+ <span class="mail-read-view__header-recipients--highlight-sender">
{{#if header.from }}
{{ header.from }}
{{else}}
@@ -33,51 +29,55 @@
<i class="fa fa-long-arrow-right"></i>
{{{formatRecipients header}}}
</div>
- <div class="recipients column large-2 text-right">
- <span class="received-date">{{ formatDate header.date }}</span>
+
+ <div class="mail-read-view__header-date">
+ {{ formatDate header.date }}
</div>
- <div class="headline-area">
- <h3 class="subjectArea column large-10 no-padding">
- <span class="subject">{{ header.subject }}</span>
- <div class="tagsArea">
- <ul class="tags">
- <i class="tags-label fa fa-tags"></i>
- {{#each tags }}
- <li class="tag" data-tag="{{this}}">{{ this }}</li>
- {{/each }}
+ <hr>
- <li class="new-tag">
- <input type="text" id="new-tag-input" placeholder="{{t 'Press Enter to add tag'}}"/>
- </li>
- <li class="add-new">
- <button id="new-tag-button" class="no-style"><i class="fa fa-plus"></i></button>
- </li>
- </ul>
- </div>
- </h3>
- <nav id="mail-actions" class="column large-2 no-padding">
- </nav>
+ <div class="mail-read-view__header-subject">
+ <h3>{{ header.subject }}</h3>
</div>
- </div>
+ <nav id="mail-actions" class="mail-read-view__header-actions"></nav>
+
+ <div class="mail-read-view__header-tags">
+ <ul class="tags">
+ <i class="tags-label fa fa-tags"></i>
+ {{#each tags }}
+ <li class="tag" data-tag="{{this}}">{{ this }}</li>
+ {{/each }}
+
+ <li class="new-tag">
+ <input type="text" id="new-tag-input" placeholder="{{t 'Press Enter to add tag'}}"
+ />
+ </li>
+
+ <li class="add-new">
+ <button id="new-tag-button" class="no-style"><i class="fa fa-plus"></i>
+ </button>
+ </li>
+ </ul>
+ </div>
</header>
- <div class="bodyArea column large-12">
- </div>
+ <iframe class="mail-read-view__body" id="read-sandbox" sandbox="allow-popups allow-scripts" src="sandbox/sandbox.html" scrolling="no"></iframe>
{{#if attachments}}
- <div class="attachmentsAreaWrap">
- <div class="attachmentsArea column large-12">
- <p><strong><i class="fa fa-paperclip"></i> {{ attachments.length }} attachment(s):</strong></p>
- <ul>
- {{#each attachments }}
- <li>
- <a href="/attachment/{{ this.ident }}?content_type={{ this.content-type }}&encoding={{ this.encoding }}&filename={{ this.name }}">{{ this.name }} ({{ formatSize this.size}})<i class="fa fa-arrow-down download-icon"></i></a>
- </li>
- {{/each }}
- </ul>
+ <hr>
+
+ <div id="attachmentsArea" class="mail-read-view__attachments">
+ <p class="mail-read-view__attachments-header"><i class="fa fa-paperclip"></i> {{ attachments.length }} attachment(s):</p>
+ <ul>
+ {{#each attachments }}
+ <li class="mail-read-view__attachments-item">
+ <a class="mail-read-view__attachments-item-label" href="/attachment/{{ this.ident }}?content_type={{ this.content-type }}&encoding={{ this.encoding }}&filename={{ this.name }}">{{ this.name }} ({{ formatSize this.size}})
+ <i class="fa fa-arrow-down mail-read-view__attachments-item-download"></i></a>
+ </li>
+ {{/each }}
+ </ul>
</div>
- </div>
{{/if}}
</div>
+
diff --git a/web-ui/app/templates/mails/mail_actions.hbs b/web-ui/app/templates/mails/mail_actions.hbs
index 94b79ab9..0b717373 100644
--- a/web-ui/app/templates/mails/mail_actions.hbs
+++ b/web-ui/app/templates/mails/mail_actions.hbs
@@ -1,6 +1,6 @@
-<button id="reply-button-top" class="no-style"><i class="fa fa-reply"></i></button>
-<button id="view-more-actions" class="no-style"><i class="fa fa-caret-down"></i></button>
-<ul id="more-actions">
- <li><span id="reply-all-button-top">{{t 'Reply to All'}}</span></li>
- <li><span id="delete-button-top">{{t 'Delete this message'}}</span></li>
+<button id="reply-button-top" class="mail-read-view__header-actions-button mail-read-view__header-actions-button--reply"><i class="fa fa-reply"></i></button>
+<button id="view-more-actions" class="mail-read-view__header-actions-button mail-read-view__header-actions-button--more"><i class="fa fa-caret-down"></i></button>
+<ul id="more-actions" class="mail-read-view__header-actions-dropdown">
+ <li id="reply-all-button-top" class="mail-read-view__header-actions-dropdown-entry">{{t 'Reply to All'}}</li>
+ <li id="delete-button-top" class="mail-read-view__header-actions-dropdown-entry">{{t 'Delete this message'}}</li>
</ul>
diff --git a/web-ui/app/templates/search/search_trigger.hbs b/web-ui/app/templates/search/search_trigger.hbs
index 2261d154..f2c410a4 100644
--- a/web-ui/app/templates/search/search_trigger.hbs
+++ b/web-ui/app/templates/search/search_trigger.hbs
@@ -1,3 +1,3 @@
<form>
- <input type="search" placeholder="{{t 'search-placeholder'}}"></input>
+ <input type="search" pattern="[a-zA-Z0-9\s]{3,}" placeholder="{{t 'search-placeholder'}}"></input>
</form>
diff --git a/web-ui/app/templates/user_alerts/message.hbs b/web-ui/app/templates/user_alerts/message.hbs
index 3a0055eb..abba1f91 100644
--- a/web-ui/app/templates/user_alerts/message.hbs
+++ b/web-ui/app/templates/user_alerts/message.hbs
@@ -1 +1 @@
-<span class="{{ message.class }}">{{ message.content }}</span>
+<span class="message-panel__growl {{ message.class }}">{{ message.content }}</span>
diff --git a/web-ui/bower.json b/web-ui/bower.json
index 263ac2e4..018a57c4 100644
--- a/web-ui/bower.json
+++ b/web-ui/bower.json
@@ -17,7 +17,8 @@
"jquery-file-upload": "~9.11.2",
"jquery-ui": "~1.11.4",
"DOMPurify": "~0.7.4",
- "he": "~0.5.0"
+ "he": "~0.5.0",
+ "iframe-resizer": "~3.5.3"
},
"devDependencies": {
"handlebars": "2.0.0",
diff --git a/web-ui/config/package.sh b/web-ui/config/package.sh
index 9b6bc66c..0bd82367 100644
--- a/web-ui/config/package.sh
+++ b/web-ui/config/package.sh
@@ -28,16 +28,24 @@ mkdir -p dist
./go handlebars
./go imagemin
./go minify_html
+./go minify_sandbox
./go add_git_version
./go buildmain
# copy files
cd app
-cp --parents 404.html fonts/* locales/**/* bower_components/font-awesome/css/font-awesome.min.css bower_components/jquery-file-upload/css/jquery.fileupload.css bower_components/font-awesome/fonts/* ../dist
+cp --parents \
+404.html \
+fonts/* \
+locales/**/* \
+bower_components/font-awesome/css/font-awesome.min.css \
+bower_components/jquery-file-upload/css/jquery.fileupload.css \
+bower_components/font-awesome/fonts/* \
+../dist
cd -
-# concat js files and minify
+# concat js files and minify for app.min.js
cat \
app/bower_components/modernizr/modernizr.js \
app/bower_components/lodash/dist/lodash.js \
@@ -51,6 +59,14 @@ app/bower_components/foundation/js/foundation.js \
app/bower_components/foundation/js/foundation/foundation.reveal.js \
app/bower_components/foundation/js/foundation/foundation.offcanvas.js \
app/js/foundation/initialize_foundation.js \
+app/bower_components/iframe-resizer/js/iframeResizer.min.js \
.tmp/app.concatenated.js > dist/app.js
node_modules/.bin/minify dist/app.js > dist/app.min.js
rm dist/app.js
+
+# concat js files and minify for sandbox.min.js
+cat \
+app/js/sandbox.js \
+app/bower_components/iframe-resizer/js/iframeResizer.contentWindow.min.js > dist/sandbox.js
+node_modules/.bin/minify dist/sandbox.js > dist/sandbox.min.js
+rm dist/sandbox.js
diff --git a/web-ui/package.json b/web-ui/package.json
index a49e32d1..0e8b9262 100644
--- a/web-ui/package.json
+++ b/web-ui/package.json
@@ -38,6 +38,7 @@
"package": "/bin/bash config/package.sh",
"imagemin": "node config/imagemin.js",
"minify_html": "node_modules/.bin/html-minifier app/index.html --collapse-whitespace | sed 's|<!--usemin_start-->.*<!--usemin_end-->|<script src=\"assets/app.min.js\" type=\"text/javascript\"></script>|' > dist/index.html",
+ "minify_sandbox": "node_modules/.bin/html-minifier app/sandbox.html --collapse-whitespace | sed 's|<!--usemin_start-->.*<!--usemin_end-->|<script src=\"sandbox.min.js\" type=\"text/javascript\"></script>|' > dist/sandbox.html",
"add_git_version": "/bin/bash config/add_git_version.sh"
}
}
diff --git a/web-ui/test/spec/helpers/sanitizer.spec.js b/web-ui/test/spec/helpers/sanitizer.spec.js
index acd4b2b2..b553583e 100644
--- a/web-ui/test/spec/helpers/sanitizer.spec.js
+++ b/web-ui/test/spec/helpers/sanitizer.spec.js
@@ -25,6 +25,12 @@ define(['helpers/sanitizer'], function (sanitizer) {
var output = sanitizer.purifyText('123<a>asd</a>');
expect(output).toEqual(expectedOutput);
});
+
+ it('should leave highlighted text untouched', function () {
+ var expectedOutput = '<em class="search-highlight">&#x31;&#x32;&#x33;&#x3C;&#x61;&#x3E;&#x61;&#x73;&#x64;&#x3C;&#x2F;&#x61;&#x3E;</em>';
+ var output = sanitizer.purifyText('<em class="search-highlight">123<a>asd</a></em>');
+ expect(output).toEqual(expectedOutput);
+ });
});
describe('sanitizer.sanitize', function () {
diff --git a/web-ui/test/spec/mail_view/ui/mail_view.spec.js b/web-ui/test/spec/mail_view/ui/mail_view.spec.js
index ae874621..29be6c2d 100644
--- a/web-ui/test/spec/mail_view/ui/mail_view.spec.js
+++ b/web-ui/test/spec/mail_view/ui/mail_view.spec.js
@@ -21,6 +21,12 @@ describeComponent('mail_view/ui/mail_view', function () {
expect(spyEvent.mostRecentCall.data.mail).toEqual(1);
});
+ it('triggers mail.highlightMailContent when receiving mail.here', function () {
+ var hightlightEvent = spyOnEvent(document,Pixelated.events.mail.highlightMailContent);
+ this.component.trigger(this.component, Pixelated.events.mail.here);
+ expect(hightlightEvent).toHaveBeenTriggeredOn(document);
+ });
+
it('triggers dispatchers.rightPane.openNoMessageSelected when getting mail.notFound', function () {
var openNoMessageSelectedEvent = spyOnEvent(document, Pixelated.events.dispatchers.rightPane.openNoMessageSelected);
@@ -29,6 +35,11 @@ describeComponent('mail_view/ui/mail_view', function () {
expect(openNoMessageSelectedEvent).toHaveBeenTriggeredOn(document);
});
+ it('should open reply container', function () {
+ var showContainerEvent = spyOnEvent(document, Pixelated.events.ui.replyBox.showReplyContainer);
+ this.component.displayMail({}, testData);
+ expect(showContainerEvent).toHaveBeenTriggeredOn(document);
+ });
it('removes the tag from the mail when the tag label is clicked', function() {
var updateSpy = spyOnEvent(document, Pixelated.events.mail.tags.update);
@@ -270,7 +281,7 @@ describeComponent('mail_view/ui/mail_view', function () {
this.component.displayMail({}, withAttachments);
- var attachmentLink = $(this.component.$node.find('.attachmentsArea li').html());
+ var attachmentLink = $(this.component.$node.find('.mail-read-view__attachments-item').html());
var expectedLink = '/attachment/912ec803b2ce49e4a541068d495ab570?content_type=text/plain&encoding=base64&filename=filename.txt';
expect(attachmentLink.attr('href')) .toBe(expectedLink);
});
diff --git a/web-ui/test/spec/mail_view/ui/no_mails_available_pane.spec.js b/web-ui/test/spec/mail_view/ui/no_mails_available_pane.spec.js
index 157e6c47..1bf79b96 100644
--- a/web-ui/test/spec/mail_view/ui/no_mails_available_pane.spec.js
+++ b/web-ui/test/spec/mail_view/ui/no_mails_available_pane.spec.js
@@ -4,17 +4,17 @@ describeComponent('mail_view/ui/no_mails_available_pane', function () {
describe('after initialization', function () {
it('renders template', function () {
this.setupComponent({tag: 'inbox'});
- expect(this.$node.html()).toMatch('<div class="text">NO EMAILS IN \'INBOX\'.</div>');
+ expect(this.$node.html()).toMatch('<div class="no-mails-available-pane">\n NO EMAILS IN \'INBOX\'.\n</div>');
});
it('show different message for search with no results', function () {
this.setupComponent({tag: 'all', forSearch: 'search'});
- expect(this.$node.html()).toMatch('<div class="text">NO RESULTS FOR: \'SEARCH\'.</div>');
+ expect(this.$node.html()).toMatch('<div class="no-mails-available-pane">\n NO RESULTS FOR: \'SEARCH\'.\n</div>');
});
it('show only tag information when listing all mails', function () {
this.setupComponent({tag: 'all', forSearch: 'in:all'});
- expect(this.$node.html()).toMatch('<div class="text">NO EMAILS IN \'ALL\'.</div>');
+ expect(this.$node.html()).toMatch('<div class="no-mails-available-pane">\n NO EMAILS IN \'ALL\'.\n</div>');
});
});
});
diff --git a/web-ui/test/spec/mail_view/ui/reply_section.spec.js b/web-ui/test/spec/mail_view/ui/reply_section.spec.js
index 9cdf7405..00709684 100644
--- a/web-ui/test/spec/mail_view/ui/reply_section.spec.js
+++ b/web-ui/test/spec/mail_view/ui/reply_section.spec.js
@@ -5,6 +5,18 @@ describeComponent('mail_view/ui/reply_section', function () {
this.setupComponent();
});
+ describe('show/hide reply container', function () {
+ it('should hide reply container until mail data is loaded', function () {
+ this.component.checkForDraftReply();
+ expect(this.component.select('replyContainer')).toBeHidden();
+ });
+
+ it('should show reply container when mail data is loaded', function () {
+ this.component.trigger(document, Pixelated.events.ui.replyBox.showReplyContainer);
+ expect(this.component.select('replyContainer')).not.toBeHidden();
+ });
+ });
+
describe('clicking reply buttons', function() {
var mailWantEvent, expectEventData;
@@ -45,6 +57,7 @@ describeComponent('mail_view/ui/reply_section', function () {
this.component.attr.replyType = 'reply';
this.component.trigger(this.component, Pixelated.events.mail.here, { mail: mailData });
+ expect(this.component.select('replyContainer')).not.toBeHidden();
expect(ReplyBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
mail: mailData,
replyType: 'reply'
@@ -55,6 +68,7 @@ describeComponent('mail_view/ui/reply_section', function () {
this.component.attr.replyType = 'replyall';
this.component.trigger(this.component, Pixelated.events.mail.here, { mail: mailData });
+ expect(this.component.select('replyContainer')).not.toBeHidden();
expect(ReplyBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
mail: mailData,
replyType: 'replyall'
@@ -65,6 +79,7 @@ describeComponent('mail_view/ui/reply_section', function () {
this.component.attr.replyType = 'forward';
this.component.trigger(this.component, Pixelated.events.mail.here, { mail: mailData });
+ expect(this.component.select('replyContainer')).not.toBeHidden();
expect(ForwardBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
mail: mailData
});
@@ -87,6 +102,7 @@ describeComponent('mail_view/ui/reply_section', function () {
$(document).trigger(Pixelated.events.ui.composeBox.trashReply);
+ expect(this.component.select('replyContainer')).not.toBeHidden();
expect(this.component.select('replyButton')).not.toBeHidden();
expect(this.component.select('replyAllButton')).not.toBeHidden();
expect(this.component.select('forwardButton')).not.toBeHidden();
diff --git a/web-ui/test/spec/search/results_highlighter.spec.js b/web-ui/test/spec/search/results_highlighter.spec.js
index cfb61e9c..13131a8e 100644
--- a/web-ui/test/spec/search/results_highlighter.spec.js
+++ b/web-ui/test/spec/search/results_highlighter.spec.js
@@ -1,9 +1,11 @@
describeComponent('search/results_highlighter', function () {
'use strict';
- it('highlights words or parts of words that match with the keywords given', function () {
+ beforeEach(function () {
this.setupComponent('<div id="text">Any one seeing too many open bugs</div>');
+ });
+ it('highlights words or parts of words that match with the keywords given', function () {
this.component.attr = {keywords: ['any']};
this.component.highlightResults(event, {where: '#text'});
@@ -12,9 +14,15 @@ describeComponent('search/results_highlighter', function () {
expect(highlightedWords).toEqual(2);
});
- it('resets highlights when a new search is performed', function() {
- this.setupComponent('<div id="text">Any one seeing too many open bugs</div>');
+ it('highlights a string with the keywords given', function () {
+ this.component.attr = {keywords: ['foo']};
+ var expectedString = 'the <em class="search-highlight">foo</em> bar';
+ var string = this.component.highlightString('the foo bar');
+
+ expect(string).toEqual(expectedString);
+ });
+ it('resets highlights when a new search is performed', function() {
this.component.attr = {keywords: ['any']};
this.component.highlightResults(event, {where: '#text'});
$(document).trigger(Pixelated.events.search.resetHighlight);
diff --git a/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
index bde3b7fa..f15cda81 100644
--- a/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
+++ b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
@@ -8,13 +8,13 @@ describeComponent('user_alerts/ui/user_alerts', function () {
it('should render message when ui:user_alerts:displayMessage is triggered', function () {
this.component.trigger(Pixelated.events.ui.userAlerts.displayMessage, { message: 'a message' });
- expect(this.component.$node.html()).toEqual('<span class="success">a message</span>\n');
+ expect(this.component.$node.html()).toEqual('<span class="message-panel__growl message-panel__growl--success">a message</span>\n');
});
it('should render error message', function () {
this.component.trigger(Pixelated.events.ui.userAlerts.displayMessage, { message: 'send failed', class: 'error' });
- expect(this.component.$node.html()).toEqual('<span class="error">send failed</span>\n');
+ expect(this.component.$node.html()).toEqual('<span class="message-panel__growl message-panel__growl--error">send failed</span>\n');
});
it('should be emptied and hidden when hide is called', function() {
@@ -24,6 +24,4 @@ describeComponent('user_alerts/ui/user_alerts', function () {
expect(this.$node.html()).toEqual('');
});
-
-
});