Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

8
gaia/apps/email/.jshintrc Executable file
View file

@ -0,0 +1,8 @@
{
"extends": "../../.jshintrc",
"predef": [
"define",
"require",
"Promise"
]
}

View file

@ -0,0 +1,21 @@
<clientConfig version="1.1">
<emailProvider id="126.com">
<domain>126.com</domain>
<displayName>126.com</displayName>
<displayShortName>126.com</displayShortName>
<incomingServer type="imap">
<hostname>imap.126.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.126.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,21 @@
<clientConfig version="1.1">
<emailProvider id="163.com">
<domain>163.com</domain>
<displayName>163.com</displayName>
<displayShortName>163.com</displayShortName>
<incomingServer type="imap">
<hostname>imap.163.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.163.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,30 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</outgoingServer>
<oauth2Settings>
<secretGroup>google</secretGroup>
<authEndpoint>https://accounts.google.com/o/oauth2/auth</authEndpoint>
<tokenEndpoint>https://accounts.google.com/o/oauth2/token</tokenEndpoint>
<scope>https://mail.google.com/</scope>
</oauth2Settings>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,30 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</outgoingServer>
<oauth2Settings>
<secretGroup>google</secretGroup>
<authEndpoint>https://accounts.google.com/o/oauth2/auth</authEndpoint>
<tokenEndpoint>https://accounts.google.com/o/oauth2/token</tokenEndpoint>
<scope>https://mail.google.com/</scope>
</oauth2Settings>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,30 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</outgoingServer>
<oauth2Settings>
<secretGroup>google</secretGroup>
<authEndpoint>https://accounts.google.com/o/oauth2/auth</authEndpoint>
<tokenEndpoint>https://accounts.google.com/o/oauth2/token</tokenEndpoint>
<scope>https://mail.google.com/</scope>
</oauth2Settings>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,30 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</outgoingServer>
<oauth2Settings>
<secretGroup>google</secretGroup>
<authEndpoint>https://accounts.google.com/o/oauth2/auth</authEndpoint>
<tokenEndpoint>https://accounts.google.com/o/oauth2/token</tokenEndpoint>
<scope>https://mail.google.com/</scope>
</oauth2Settings>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,30 @@
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>xoauth2</authentication>
</outgoingServer>
<oauth2Settings>
<secretGroup>google</secretGroup>
<authEndpoint>https://accounts.google.com/o/oauth2/auth</authEndpoint>
<tokenEndpoint>https://accounts.google.com/o/oauth2/token</tokenEndpoint>
<scope>https://mail.google.com/</scope>
</oauth2Settings>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,27 @@
<clientConfig version="1.1">
<emailProvider id="hotmail.com">
<domain>hotmail.com</domain>
<domain>hotmail.co.uk</domain>
<domain>hotmail.co.jp</domain>
<domain>hotmail.com.br</domain>
<domain>hotmail.de</domain>
<domain>hotmail.fr</domain>
<domain>hotmail.it</domain>
<domain>hotmail.es</domain>
<domain>live.com</domain>
<domain>live.co.uk</domain>
<domain>live.co.jp</domain>
<domain>live.de</domain>
<domain>live.fr</domain>
<domain>live.it</domain>
<domain>live.jp</domain>
<domain>msn.com</domain>
<domain>outlook.com</domain>
<displayName>Microsoft Live Hotmail</displayName>
<displayShortName>Hotmail</displayShortName>
<incomingServer type="activesync">
<server>https://m.hotmail.com</server>
<username>%EMAILADDRESS%</username>
</incomingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,36 @@
<clientConfig version="1.1">
<emailProvider id="t-online.de">
<domain>t-online.de</domain>
<displayName>T-Online</displayName>
<displayShortName>T-Online</displayShortName>
<incomingServer type="imap">
<hostname>secureimap.t-online.de</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>securepop.t-online.de</hostname>
<port>995</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>securesmtp.t-online.de</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
<enable visiturl="https://kundencenter.telekom.de/kundencenter/registriert/dienste-abos/ngcs/email-einstellungen/index.html">
<instruction>You need to set up an E-Mail-Password. Attention: The normal password for the T-Online-Website and Webmail does *not* work for email aplications.</instruction>
<instruction lang="de">Sie müssen ein E-Mail-Passwort einrichten. Achtung: Das Passwort für die T-Online-Website und Webmail funktioniert *nicht* für E-Mail-Programme!</instruction>
</enable>
<documentation url="http://hilfe.telekom.de/hsp/cms/content/HSP/de/3378/theme-45858870/theme-45858718/theme-45858716/theme-45858715/faq-45855229">
<descr lang="en">Set up your email address</descr>
<descr>Richten Sie Ihre Email-Adresse ein</descr>
</documentation>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,21 @@
<clientConfig version="1.1">
<emailProvider id="yahoo.com">
<domain>yahoo.com</domain>
<displayName>Yahoo! Mail</displayName>
<displayShortName>Yahoo</displayShortName>
<incomingServer type="imap">
<hostname>imap.mail.yahoo.co.jp</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.mail.yahoo.co.jp</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
</emailProvider>
</clientConfig>

View file

@ -0,0 +1,21 @@
<clientConfig version="1.1">
<emailProvider id="yahoo.com">
<domain>yahoo.com</domain>
<displayName>Yahoo! Mail</displayName>
<displayShortName>Yahoo</displayShortName>
<incomingServer type="imap">
<hostname>imap.mail.yahoo.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.mail.yahoo.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</outgoingServer>
</emailProvider>
</clientConfig>

367
gaia/apps/email/build/build.js Executable file
View file

@ -0,0 +1,367 @@
'use strict';
/* jshint node: true, evil: true */
var utils = require('utils');
var esomin = require('esomin');
var { Cc, Ci } = require('chrome');
var converter =
Cc['@mozilla.org/intl/scriptableunicodeconverter'].
createInstance(Ci.nsIScriptableUnicodeConverter);
converter.charset = 'UTF-8';
var secClass = Cc['@mozilla.org/security/hash;1'];
var nsICryptoHash = Ci.nsICryptoHash;
var digests = [];
var appName;
// FIXME: It will be replace with webapp-shared.js
// Taken from r.js css optimizing step.
var cssImportRegExp = /\@import\s+(url\()?\s*([^);]+)\s*(\))?([\w, ]*)(;)?/ig;
function stripCssComments(contents) {
var index,
start = 0,
end = contents.length,
result = '',
startComment = '/*',
endComment = '*/';
while ((index = contents.indexOf(startComment, start)) !== -1) {
result += contents.substring(start, index);
start = index;
index = contents.indexOf(endComment, start);
if (index === -1) {
start = end - 1;
} else {
start = index + 2;
}
}
if (start !== end - 1) {
result += contents.substring(start, end);
}
return result;
}
/**
* Find javascript dependencies in shared directory for all JS files in js
* directory for each app directory, and also call onFileRead for all js and
* HTML files.
* @param {Object} parse Parser module from r.js
* @param {String} appDir App directory in gaia source tree
* @param {RegExp} exclude exclude expression for js files
* @param {Function} onFileRead Event handler when file contents has been
* retrieved.
* @return {String[]} javascript dependencies in shared directory
*/
function getSharedJs(parse, appDir, exclude, onFileRead) {
var sharedJs = [];
var jsExtRegExp = /\.js$/;
var files = utils.ls(utils.getFile(appDir, 'js'), true)
.filter(function(file) {
return /\.(js|html)/.test(file.path) && file.isFile();
});
files.forEach(function(file) {
var contents = utils.getFileContent(file);
onFileRead(contents);
// If JS, scan for shared resources.
if (jsExtRegExp.test(file.path) && !exclude.test(file.path)) {
var deps = parse.findDependencies(file.path, contents);
deps.forEach(function (dep) {
if (dep.indexOf('shared/') === 0) {
var sharedDep = dep.replace(/shared\/js\//, '') + '.js';
// Avoid duplicate entries for cleanliness
if (sharedJs.indexOf(sharedDep) === -1) {
sharedJs.push(sharedDep);
}
}
});
}
});
return sharedJs;
}
/**
* Find CSS references in shared directory for all the CSS files and call
* onFileRead() in style directory for each app directory.
* @param {String} appDir App directory in gaia source tree
* @param {[type]} onFileRead Event handler when file contents has been
* retrieved.
* @return {String[][]} an array contains style and style_unstable css
* files in shared directory.
*/
function getSharedStyles(appDir, onFileRead) {
var sharedStyle = [];
var sharedStyleUnstable = [];
var sharedElements = [];
var files = utils.ls(utils.getFile(appDir, 'style'), true)
.filter(function(file) {
return /\.css$/.test(file.path);
});
files.forEach(function(file) {
var url, match, index,
contents = utils.getFileContent(file);
onFileRead(contents);
contents = stripCssComments(contents);
cssImportRegExp.lastIndex = 0;
while ((match = cssImportRegExp.exec(contents))) {
// Grab the URL without quotes
url = match[2].replace(/['"]/g, '');
if (url) {
index = url.indexOf('/shared/');
if (index !== -1) {
if (url.indexOf('shared/elements') !== -1) {
sharedElements.push(url.substring(index + 1)
.replace(/shared\/elements\//, ''));
} else if (url.indexOf('style_unstable') === -1) {
sharedStyle.push(url.substring(index + 1)
.replace(/shared\/style\//, ''));
} else {
sharedStyleUnstable.push(url.substring(index + 1)
.replace(/shared\/style_unstable\//, ''));
}
}
}
}
});
return [sharedStyle, sharedStyleUnstable, sharedElements];
}
// Adapted from:
// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsICryptoHash#Computing_the_Hash_of_a_String
// return the two-digit hexadecimal code for a byte
function toHexString(charCode) {
return ('0' + charCode.toString(16)).slice(-2);
}
/**
* Copy the services.json file to the root of the staging dir.
*/
function generateServicesConfig(options) {
var path = options.EMAIL_SERVICES_PATH,
stageJsPath = utils.joinPath(options.STAGE_APP_DIR, 'js');
// Just copy over the default file
if (!path) {
var sourcePath = utils.joinPath(options.APP_DIR, 'js', 'services.js');
utils.copyFileTo(sourcePath, stageJsPath, 'services.js');
return;
}
// read the pretty JSON
var services = utils.readJSONFromPath(path);
// and write out ugly JSON
utils.writeContent(
utils.getFile(stageJsPath, 'services.js'),
'define(' + JSON.stringify(services) + ');');
}
function onFileRead(contents) {
digests.push(getDigest(contents));
}
function getDigest(contents) {
var result = {};
// data is an array of bytes
var data = converter.convertToByteArray(contents, result);
var ch = secClass.createInstance(nsICryptoHash);
ch.init(ch.SHA1);
ch.update(data, data.length);
var hash = ch.finish(false);
// convert the binary hash data to a hex string.
return hash.split('').map(function(char, i) {
return toHexString(hash.charCodeAt(i));
}).join('');
}
function writeCacheValue(options) {
// Update the cache value based on digest values of all files.
var finalDigest = getDigest(digests.join(',')),
cacheRegExp = /HTML_CACHE_VERSION\s*=\s*["'][^"']+["']/,
cacheFile = utils.getFile(options.STAGE_APP_DIR, 'js',
'html_cache_restore.js'),
contents = utils.getFileContent(cacheFile);
contents = contents.replace(cacheRegExp,
'HTML_CACHE_VERSION = \'' + finalDigest + '\'');
utils.writeContent(cacheFile, contents);
}
function runOptimizer(args, requirejs) {
var build = new Promise(function(resolve, reject) {
requirejs.optimize(args, resolve, reject);
});
return build.then(function() {
utils.log(appName, 'require.js optimize done');
})
.catch(function(err) {
utils.log(appName, 'require.js optimize failed');
utils.log(appName, err);
});
}
function optimize(options, r) {
var gelamConfigFile = utils.getFile(options.APP_DIR,
'build', 'gelam_worker.build.js');
var mainFrameConfigFile = utils.getFile(options.APP_DIR,
'build', 'main-frame-setup.build.js');
var appConfigFile = utils.getFile(options.APP_DIR, 'build', 'email.build.js');
var stageShared = utils.getFile(options.STAGE_APP_DIR, 'shared');
var stageJs = utils.getFile(options.STAGE_APP_DIR, 'js');
var extPrefix = /^.*[\\\/]email[\\\/]js[\\\/]ext[\\\/]/;
utils.ensureFolderExists(stageShared);
utils.ensureFolderExists(stageJs);
// Make sure services.js is up to date.
generateServicesConfig(options);
// Do gelam worker stuff first. This will copy over all of the js/ext
// directory.
var logLevel = 'logLevel=' + (options.VERBOSE === '1' ? '0' : '4');
return runOptimizer([gelamConfigFile.path, logLevel], r)
.then(function() {
// Now do main-frame-setup build for the main thread side of gelam. It is
// a single file optimization, so need to manually delete files it combines
// from the staging area.
// Manually load and eval the options so that a function callback can be
// added to the build options.
var mainFrameOptions = eval('(' +
utils.getFileContent(mainFrameConfigFile) +
')');
// Up the log level so we can see what was built. By default, passing object
// args to r.js will run it in silent mode.
mainFrameOptions.logLevel = (options.VERBOSE === '1') ? 0 : 4;
// Update paths, since it is now relative to the current working directory
// of this script, not the gelamConfigFile.
mainFrameOptions.baseUrl = utils.getFile(options.APP_DIR, 'js').path;
mainFrameOptions.out = utils.getFile(options.STAGE_APP_DIR,
'js', 'ext', 'main-frame-setup.js').path;
// Minification handled later by esomin
mainFrameOptions.optimize = 'none';
mainFrameOptions.onModuleBundleComplete = function(data) {
// Called on layer completion. Get data.included for included files and
// remove those files from build_stage. This does not happen automatically
// because the gelam build is a single file optimization target from the
// source area to build_stage, so it does not know of the other files that
// are already in build_stage as the result of the main gaia app build.
data.included.forEach(function(sourceLocation) {
var destFile = utils.getFile(options.STAGE_APP_DIR, 'js', 'ext',
sourceLocation.replace(extPrefix, ''));
if (destFile.exists() &&
destFile.path.indexOf('main-frame-setup') === -1) {
destFile.remove(false);
}
});
};
return runOptimizer(mainFrameOptions, r);
})
.then(function() {
// Now the rest of the gaia app optimization. This build run explicitly
// ignores the ext directory.
return runOptimizer([appConfigFile.path, logLevel], r);
});
}
function removeLoader(options) {
var indexFile = utils.getFile(options.STAGE_APP_DIR, 'index.html');
var indexContent = utils.getFileContent(indexFile);
utils.writeContent(indexFile,
indexContent.replace(/data-loader="[^"]+"/, ''));
return options;
}
function removeFiles(options) {
var files = [
utils.getFile(options.STAGE_APP_DIR, 'js','tmpl_builder.js'),
utils.getFile(options.STAGE_APP_DIR, '.jshintrc')
];
files.forEach(function(file) {
if (utils.fileExists(file.path)) {
file.remove(false);
}
});
}
function getParse(r) {
return new Promise(function(resolve, reject) {
r.tools.useLib(function(req) {
req(['parse'], function(parse) {
resolve(parse);
});
});
});
}
exports.execute = function(options) {
appName = utils.basename(options.APP_DIR);
var shared = {
js: [],
style: [],
style_unstable: []
};
var backendRegExp = /[\\\/]js[\\\/]ext[\\\/]/;
var sharedJsonFile = utils.getFile(options.STAGE_APP_DIR, 'gaia_shared.json');
var stageAppDir = utils.getFile(options.STAGE_APP_DIR);
utils.ensureFolderExists(stageAppDir);
var rjsPath = utils.joinPath(options.GAIA_DIR, 'build', 'r.js');
var requirejs;
if (utils.isNode()) {
requirejs = require(rjsPath);
} else {
var sandbox = utils.createSandbox();
sandbox.arguments = [];
sandbox.requirejsAsLib = true;
sandbox.print = function() {
utils.log(appName, Array.prototype.join.call(arguments, ' '));
};
utils.runScriptInSandbox(rjsPath, sandbox);
requirejs = sandbox.requirejs;
}
var promises = [
optimize(options, requirejs),
getParse(requirejs)
];
return Promise.all(promises)
.then(function(result) {
var [buildText, parse] = result;
shared.js = getSharedJs(parse, options.APP_DIR,
backendRegExp, onFileRead);
[shared.style, shared.style_unstable] =
getSharedStyles(options.APP_DIR, onFileRead);
utils.writeContent(sharedJsonFile, JSON.stringify(shared, null, 2));
writeCacheValue(options);
removeLoader(options);
removeFiles(options);
})
.then(function() {
if (options.GAIA_OPTIMIZE === '1') {
utils.log(appName, 'esomin minify done');
return esomin.minifyDir(stageAppDir);
}
})
.catch(function(err) {
utils.log(err)
utils.log(appName, 'running customize build failed');
throw err;
});
};

View file

@ -0,0 +1,87 @@
{
appDir: '..',
baseUrl: 'js',
dir: '../../../build_stage/email',
mainConfigFile: '../js/config.js',
paths: {
prim: 'empty:'
},
/*
wrap: {
start: 'plog("@@@ START OF BUILD LAYER");',
end: 'plog("@@@ END OF BUILD LAYER");'
},
*/
// Be sure to normalize all define() calls by extracting
// dependencies so Function toString is not needed, and
// lower capability devices like Tarako can optimize
// memory by discarding function sources. This is
// automatically done when an 'optimize' value other than
// 'none' is used. This setting makes sure it happens for
// builds where 'none' is used for 'optimize'.
normalizeDirDefines: 'all',
// Rewrite the waitSeconds config so that we never time out
// waiting for modules to load in production. See js/config.js
// for more details.
onBuildWrite: function(id, url, contents) {
if (id === 'config') {
return contents.replace(/waitSeconds:\s*\d+/, 'waitSeconds: 0');
} else {
return contents;
}
},
modules: [
{
name: 'config',
include: [
'alameda',
'l10nbase',
'moz_intl',
'tmpl',
'text',
'value_selector',
'folder_depth_classes',
'iframe_shims',
'cards/editor_mixins',
// gesture_detector used by both compose and message_reader layer, so
// just include it in the base layer.
'shared/js/gesture_detector',
// Bundle most likely card
'cards/message_list'
]
},
{
name: 'cards/compose',
exclude: ['config']
},
{
name: 'cards/message_reader',
exclude: ['config']
}
],
// Optimization is handled by build.js
optimize: 'none',
// Just strip comments, no code compression or mangling.
// Only active if optimize: 'uglify2'
uglify2: {
// Comment out the output section to get rid of line
// returns and tabs spacing.
output: {
beautify: true
}
},
fileExclusionRegExp: /^\.|^test$|^build$|^docs$|^ext$|^services.js$/,
// Keeping build dir since Makefile cleans it up and
// preps build dir with the shared directory
keepBuildDir: true,
removeCombined: true
}

View file

@ -0,0 +1,110 @@
{
appDir: '../js/ext',
baseUrl: '.',
dir: '../../../build_stage/email/js/ext',
mainConfigFile: '../js/ext/worker-config.js',
// Do not optimize here, the general gaia email optimization
// pass will do that, if it is desired.
optimize: 'none',
// Do not try to optimize CSS, that will be done in gaia
// email build
optimizeCss: 'none',
// Be sure to normalize all define() calls by extracting
// dependencies so Function toString is not needed, and
// lower capability devices like Tarako can optimize
// memory by discarding function sources. This is
// automatically done when an 'optimize' value other than
// 'none' is used. This setting makes sure it happens for
// builds where 'none' is used for 'optimize'.
normalizeDirDefines: 'all',
// Remove the importScripts loading in worker-bootstrap source, since
// the assets will be inlined.
pragmas: {
buildExclude: true
},
onBuildWrite: function (id, path, contents) {
if (id === 'worker-bootstrap') {
// The loading kickoff is stripped out from the source worker-bootstrap by
// a pragma, because we want this to be at the end of the file, just for
// cleanliness and code understanding, after all the defines are done.
return contents + '\nrequire([\'worker-setup\']);';
} else {
return contents;
}
},
modules: [
{
// There is enough cross reference of shared dependencies that we risk
// loading duplicates for any other layers so just optimizing the initial
// one used at worker startup. We are making a tradeoff between memory vs
// initial load time. Since the email front end can start up without the
// full load of the rest of the code in the worker favor saving memory.
name: 'worker-bootstrap',
include: [
'alameda',
'worker-config',
'worker-setup',
// Searches can happen offline.
'searchfilter',
// Job/operations are currently not gated, although they could be...
'jobmixins',
'jobs/outbox',
'drafts/jobs',
// Common account logic is required for everything.
'accountmixins',
// Include the chews because they are common and small-ish.
'htmlchew',
'quotechew',
'mailchew',
// main-frame-setup also wants this, so will delete it after the main
// gaia app optimization runs, so include it now. Commonly needed at
// some point during startup anyway.
'addressparser',
// Other common-ish utilities.
'tcp-socket',
'mix',
'axe',
'errorutils',
'db/folder_info_rep',
'mimetypes',
'mimefuncs'
]
},
{
name: 'composite/configurator',
exclude: ['worker-bootstrap']
},
{
name: 'activesync/configurator',
// activesync/protocol needed for autodiscovery.
exclude: ['worker-bootstrap', 'activesync/protocol']
}
],
// Just strip comments, no code compression or mangling.
// Only active if optimize: 'uglify2'
uglify2: {
// Comment out the output section to get rid of line
// returns and tabs spacing.
output: {
beautify: true
}
},
// Keeping build dir since Makefile cleans it up and
// preps build dir with the shared directory
keepBuildDir: true,
removeCombined: true
}

View file

@ -0,0 +1,9 @@
{
baseUrl: '../js',
out: '../../../build_stage/email/js/ext/main-frame-setup.js',
optimize: 'none',
name: 'ext/main-frame-setup',
exclude: ['evt']
}

62
gaia/apps/email/index.html Executable file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- The theme color starts off black. The card logic wants to choose a start
card first, since the color schemes are different for New Account vs Message
List. This means email starts up with a black header that fades to the correct
color once the card logic makes a choice. Due to the html cache, a black
status color is shown at first, then fades to the right color. This is
preferred to the alternative of starting up in default orange. If New Account
is the start card, it looks gross to have the orange on top of the white card
for a moment. -->
<meta name="theme-color" data-statuscolor="#ff7d14" content="#000000">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<title>Email</title>
<link rel="stylesheet" type="text/css" href="style/mail.css">
<!-- Localization -->
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="locales/email.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<!-- These are lazy loaded. Need to be included here so the build picks them up
<link rel="stylesheet" type="text/css" href="shared/style/input_areas.css" >
<link rel="stylesheet" type="text/css" href="shared/style/buttons.css" >
<link rel="stylesheet" type="text/css" href="shared/style/headers.css" >
<link rel="stylesheet" type="text/css" href="shared/style/switches.css" >
<link rel="stylesheet" type="text/css" href="shared/style/status.css" >
<link rel="stylesheet" type="text/css" href="shared/style/confirm.css" >
<link rel="stylesheet" type="text/css" href="shared/style/action_menu.css" >
<link rel="stylesheet" type="text/css" href="shared/style/progress_activity.css" >
<link rel="stylesheet" type="text/css" href="shared/style/tabs.css" >
<link rel="stylesheet" type="text/css" href="shared/style/value_selector.css" >
-->
<!--
The following scripts are lazy loaded but left here to ensure
they get copied over from shared during the build
-->
<!-- <script defer src="/shared/js/l10n.js"></script> -->
<!-- <script defer src="/shared/js/moz_intl.js"></script> -->
<!-- <script defer src="/shared/js/mime_mapper.js"></script> -->
<!-- <script defer src="/shared/js/gesture_detector.js"></script> -->
<!-- <script defer src="/shared/js/notification_helper.js"></script> -->
<!-- <script defer src="/shared/js/accessibility_helper.js"></script> -->
</head>
<body role="application">
<!-- All the active cards live (horizontally) in this container. We leave
it up to the layout engine to be smart about not painting cards that are
not visible, but we will revisit that after profiling. (display: hidden
should be fairly safe, for example.) -->
<div id="cardContainer">
<div id="cards" style="left: 0px;"></div>
</div>
<script data-targetid="cards"
data-loader="js/alameda.js"
data-loadsrc="js/config.js"
src="js/html_cache_restore.js"></script>
</body>
</html>

View file

@ -0,0 +1,62 @@
'use strict';
define(function(require, exports, module) {
var attachmentName = require('attachment_name'),
queryUri = require('query_uri');
return function activityComposeR(rawActivity) {
// Parse the activity request.
var source = rawActivity.source;
var data = source.data;
var activityName = source.name;
var dataType = data.type;
var url = data.url || data.URI;
var attachData;
if (dataType === 'url' && activityName === 'share') {
attachData = {
body: url
};
} else {
attachData = queryUri(url);
attachData.attachmentBlobs = data.blobs || [];
attachData.attachmentNames = data.filenames || [];
attachmentName.ensureNameList(attachData.attachmentBlobs,
attachData.attachmentNames);
}
return {
onComposer: function(composer, composeCard) {
var attachmentBlobs = attachData.attachmentBlobs;
/* to/cc/bcc/subject/body all have default values that shouldn't
be clobbered if they are not specified in the URI*/
if (attachData.to) {
composer.to = attachData.to;
}
if (attachData.subject) {
composer.subject = attachData.subject;
}
if (attachData.body) {
composer.body = { text: attachData.body + composer.body.text };
}
if (attachData.cc) {
composer.cc = attachData.cc;
}
if (attachData.bcc) {
composer.bcc = attachData.bcc;
}
if (attachmentBlobs) {
var attachmentsToAdd = [];
for (var iBlob = 0; iBlob < attachmentBlobs.length; iBlob++) {
attachmentsToAdd.push({
name: attachData.attachmentNames[iBlob],
blob: attachmentBlobs[iBlob]
});
}
composeCard.addAttachmentsSubjectToSizeLimits(attachmentsToAdd);
}
}
};
};
});

1224
gaia/apps/email/js/alameda.js Executable file

File diff suppressed because it is too large Load diff

50
gaia/apps/email/js/app_self.js Executable file
View file

@ -0,0 +1,50 @@
/**
* Provides a wrapper over the mozApps.getSelf() API. Structured as an
* evt emitter, with "latest" support, and "latest" is overridden so
* that the call to getSelf() is delayed until the very first need
* for it.
*
* This allows code to have a handle on this module, instead of making
* the getSelf() call, and then only trigger the fetch via a call to
* latest, delaying the work until it is actually needed. Once getSelf()
* is fetched once, the result is reused.
*/
'use strict';
define(function(require, exports, module) {
var evt = require('evt');
var appSelf = evt.mix({}),
mozApps = navigator.mozApps,
oldLatest = appSelf.latest,
loaded = false;
if (!mozApps) {
appSelf.self = {};
loaded = true;
}
function loadSelf() {
mozApps.getSelf().onsuccess = function(event) {
loaded = true;
var app = event.target.result;
appSelf.self = app;
appSelf.emit('self', appSelf.self);
};
}
// Override latest to only do the work when something actually wants to
// listen.
appSelf.latest = function(id) {
if (!loaded) {
loadSelf();
}
if (id !== 'self') {
throw new Error(module.id + ' only supports "self" property');
}
return oldLatest.apply(this, arguments);
};
return appSelf;
});

23
gaia/apps/email/js/array.js Executable file
View file

@ -0,0 +1,23 @@
'use strict';
define(function(require) {
var array = {
/**
* @param {Array} array some array.
* @param {Function} callback function to test for each element.
* @param {Object} thisObject object to use as this for callback.
*/
indexOfGeneric: function(array, callback, thisObject) {
var result = -1;
array.some(function(value, index) {
if (callback.call(thisObject, value)) {
result = index;
return true;
}
});
return result;
}
};
return array;
});

View file

@ -0,0 +1,44 @@
'use strict';
define(function(require) {
var mozL10n = require('l10n!'),
mapper = require('shared/js/mime_mapper');
var attachmentName = {
/**
* Given a blob, and a possible name, make sure a text name
* is constructed. If name is already a value, that is used,
* otherwise, the blob type and the count are used to generate
* a name.
* @param {Blob} blob the blog associated with the attachment.
* @param {String} [name] possible existing name.
* @param {Number} [count] a count to use in the generated name.
* @return {String}
*/
ensureName: function(blob, name, count) {
if (!name) {
count = count || 1;
var suffix = mapper.guessExtensionFromType(blob.type);
name = mozL10n.get('default-attachment-filename', { n: count }) +
(suffix ? ('.' + suffix) : '');
}
return name;
},
/**
* Given an array of blobs and a corresponding array of file names,
* make sure there is a file name entry for each blob. If a file name
* is missing, generate one using part of the mime type of the blob
* as the file extension in the name. This method MODIFIES the
* names array, and expects names to be an array.
* @param {Array} blobs the blobs that need names.
* @return {Array} Array of strings.
*/
ensureNameList: function(blobs, names) {
for (var i = 0; i < blobs.length; i++) {
names[i] = attachmentName.ensureName(blobs[i], names[i], i + 1);
}
}
};
return attachmentName;
});

924
gaia/apps/email/js/cards.js Executable file
View file

@ -0,0 +1,924 @@
'use strict';
define(function(require, exports, module) {
var cardsInit = require('cards_init'),
htmlCache = require('html_cache'),
mozL10n = require('l10n!'),
evt = require('evt'),
toaster = require('toaster'),
transitionEnd = require('transition_end'),
hookupInputAreaResetButtons = require('input_areas');
function addClass(domNode, name) {
if (domNode) {
domNode.classList.add(name);
}
}
function removeClass(domNode, name) {
if (domNode) {
domNode.classList.remove(name);
}
}
/**
* Fairly simple card abstraction with support for simple horizontal animated
* transitions. We are cribbing from deuxdrop's mobile UI's cards.js
* implementation created jrburke.
*/
var cards = {
_cardDefs: {},
/*
* Existing cards, left-to-right, new cards getting pushed onto the right.
*/
_cardStack: [],
activeCardIndex: -1,
/*
* @oneof[null @listof[cardName modeName]]{
* If a lazy load is causing us to have to wait before we push a card, this
* is the type of card we are planning to push. This is used by hasCard
* to avoid returning misleading answers while an async push is happening.
* }
*/
_pendingPush: null,
/**
* Cards can stack on top of each other, make sure the stacked set is
* visible over the lower sets.
*/
_zIndex: 0,
/**
* The DOM node that contains the _containerNode ("#cardContainer") and which
* we inject popup and masking layers into. The choice of doing the popup
* stuff at this layer is arbitrary.
*/
_rootNode: null,
/**
* The "#cardContainer" node which serves as the scroll container for the
* contained _cardsNode ("#cards"). It is as wide as the viewport.
*/
_containerNode: null,
/**
* The "#cards" node that holds the cards; it is as wide as all of the cards
* it contains and has its left offset changed in order to change what card
* is visible.
*/
_cardsNode: null,
/**
* The DOM nodes that should be removed from their parent when our current
* transition ends.
*/
_animatingDeadDomNodes: [],
/**
* Tracks the number of transition events per card animation. Since each
* animation ends up with two transitionend events since two cards are
* moving, need to wait for the last one to be finished before doing
* cleanup, like DOM removal.
*/
_transitionCount: 0,
/**
* Tracks if startup events have been emitted. The events only need to be
* emitted once.
* @type {Boolean}
*/
_startupEventsEmitted: false,
/**
* Is a popup visible, suggesting that any click that is not on the popup
* should be taken as a desire to close the popup? This is not a boolean,
* but rather info on the active popup.
*/
_popupActive: null,
/**
* Are we eating all click events we see until we transition to the next
* card (possibly due to a call to pushCard that has not yet occurred?).
* Set by calling `eatEventsUntilNextCard`.
*/
_eatingEventsUntilNextCard: false,
/**
* Initialize and bind ourselves to the DOM which should now be fully loaded.
*/
init: function() {
this._rootNode = document.body;
this._containerNode = document.getElementById('cardContainer');
this._cardsNode = document.getElementById('cards');
this._statusColorMeta = document.querySelector('meta[name="theme-color"]');
toaster.init(this._containerNode);
this._containerNode.addEventListener('click',
this._onMaybeIntercept.bind(this),
true);
// XXX be more platform detecty. or just add more events. unless the
// prefixes are already gone with webkit and opera?
transitionEnd(this._cardsNode, this._onTransitionEnd.bind(this), false);
// Listen for visibility changes to let current card know of them too.
// Do this here instead of each card needing to listen, and needing to know
// if it is also the current card.
document.addEventListener('visibilitychange', function(evt) {
var card = this._cardStack[this.activeCardIndex];
if (card && card.onCurrentCardDocumentVisibilityChange) {
card.onCurrentCardDocumentVisibilityChange(document.hidden);
}
}.bind(this));
cardsInit(this);
},
/**
* If the tray is active and a click happens in the tray area, transition
* back to the visible thing (which must be to our right currently.)
*/
_onMaybeIntercept: function(event) {
if (this._eatingEventsUntilNextCard) {
event.stopPropagation();
event.preventDefault();
return;
}
if (this._popupActive) {
event.stopPropagation();
event.preventDefault();
this._popupActive.close();
return;
}
// Find the card containing the event target.
var cardNode = event.target;
for (cardNode = event.target; cardNode; cardNode = cardNode.parentElement) {
if (cardNode.classList.contains('card')) {
break;
}
}
},
/**
* Push a card onto the card-stack.
*/
/* @args[
* @param[type]
* @param[showMethod @oneof[
* @case['animate']{
* Perform an animated scrolling transition.
* }
* @case['immediate']{
* Immediately warp to the card without animation.
* }
* @case['none']{
* Don't touch the view at all.
* }
* ]]
* @param[args Object]{
* An arguments object to provide to the card's constructor when
* instantiating.
* }
* @param[placement #:optional @oneof[
* @case[undefined]{
* The card gets pushed onto the end of the stack.
* }
* @case['left']{
* The card gets inserted to the left of the current card.
* }
* @case['right']{
* The card gets inserted to the right of the current card.
* }
* }
* ]
*/
pushCard: function(type, showMethod, args, placement) {
var cardDef = this._cardDefs[type];
args = args || {};
if (!cardDef) {
var cbArgs = Array.slice(arguments);
this._pendingPush = [type];
// Only eat clicks if the card will be visibly displayed.
if (showMethod !== 'none') {
this.eatEventsUntilNextCard();
}
require(['element!cards/' + type], function(Ctor) {
this._cardDefs[type] = Ctor;
this.pushCard.apply(this, cbArgs);
}.bind(this));
return;
}
this._pendingPush = null;
console.log('pushCard for type: ' + type);
var domNode = args.cachedNode || new cardDef();
if (args && domNode.onArgs) {
domNode.onArgs(args);
}
var cardIndex, insertBuddy;
if (!placement) {
cardIndex = this._cardStack.length;
insertBuddy = null;
domNode.classList.add(cardIndex === 0 ? 'before' : 'after');
}
else if (placement === 'left') {
cardIndex = this.activeCardIndex++;
insertBuddy = this._cardsNode.children[cardIndex];
domNode.classList.add('before');
}
else if (placement === 'right') {
cardIndex = this.activeCardIndex + 1;
if (cardIndex >= this._cardStack.length) {
insertBuddy = null;
} else {
insertBuddy = this._cardsNode.children[cardIndex];
}
domNode.classList.add('after');
}
this._cardStack.splice(cardIndex, 0, domNode);
if (!args.cachedNode) {
this._cardsNode.insertBefore(domNode, insertBuddy);
}
// If the card has any <button type="reset"> buttons,
// make them clear the field they're next to and not the entire form.
// See input_areas.js and shared/style/input_areas.css.
hookupInputAreaResetButtons(domNode);
// Only do auto font size watching for cards that do not have more
// complicated needs, like message_list, which modifies children contents
// that are not caught by the font_size_util.
if (!domNode.callHeaderFontSize) {
// We're appending new elements to DOM so to make sure headers are
// properly resized and centered, we emit a lazyload event.
// This will be removed when the gaia-header web component lands.
window.dispatchEvent(new CustomEvent('lazyload', {
detail: domNode
}));
}
if ('postInsert' in domNode) {
domNode.postInsert();
}
if (showMethod !== 'none') {
// make sure the reflow sees the new node so that the animation
// later is smooth.
if (!args.cachedNode) {
domNode.clientWidth;
}
this._showCard(cardIndex, showMethod, 'forward');
}
if (args.onPushed) {
args.onPushed(domNode);
}
},
/**
* Pushes a new card if none exists, otherwise, uses existing
* card and passes args to that card via tellCard. Arguments
* are the same as pushCard.
* @return {Boolean} true if card was pushed.
*/
pushOrTellCard: function(type, showMethod, args, placement) {
var query = type;
if (this.hasCard(query)) {
this.tellCard(query, args);
return false;
} else {
this.pushCard.apply(this, Array.slice(arguments));
return true;
}
},
/**
* Sets the status bar color. The element, or any of its children, can specify
* the color by setting data-statuscolor to one of the following values:
* - default: uses the default data-statuscolor set on the meta theme-color
* tag is used.
* - background: the CSS background color, via getComputedStyle, is used. This
* is useful if the background that is desired is not the one from the element
* itself, but from one of its children.
* - a specific color value.
*
* If no data-statuscolor attribute is found, then the background color for
* the element, via getComputedStyle, is used. If that value is not a valid
* color value, then the default statuscolor on the meta tag is used.
*
* Note that this method uses getComputedStyle. This could be expensive
* depending on when it is called. For the card infrastructure, since it is
* done as part of a card transition, and done before the card transition code
* applies transition styles, the target element should not be visible at the
* time of the query. In practice no negligble end user effect has been seen,
* and that query is much more desirable than hardcoding colors in JS or HTML.
*
* @param {Element} [element] the card element of interest. If no element is
* passed, the the current card is used.
*/
setStatusColor: function(element) {
var color;
// Some use cases, like dialogs, are outside the card stack, so they may
// not know what element to use for a baseline. In those cases, Cards
// decides the target element.
if (!element) {
element = this._cardStack[this.activeCardIndex];
}
// Try first for specific color override. Do a node query, since for custom
// elements, the custom elment tag may not set its color, but the template
// used inside the tag may.
var statusElement = element.dataset.statuscolor ? element :
element.querySelector('[data-statuscolor]');
if (statusElement) {
color = statusElement.dataset.statuscolor;
// Allow cards to just indicate they want the default.
if (color === 'default') {
color = null;
} else if (color === 'background') {
color = getComputedStyle(statusElement).backgroundColor;
}
} else {
// Just use the background color of the original element.
color = getComputedStyle(element).backgroundColor;
}
// Only use specific color values, not values like 'transparent'.
if (color && color.indexOf('rgb') !== 0 && color.indexOf('#') !== 0) {
color = null;
}
color = color || this._statusColorMeta.dataset.statuscolor;
var existingColor = this._statusColorMeta.getAttribute('content');
if (color !== existingColor) {
this._statusColorMeta.setAttribute('content', color);
}
},
_findCardUsingType: function(type) {
for (var i = 0; i < this._cardStack.length; i++) {
var domNode = this._cardStack[i];
if (htmlCache.nodeToKey(domNode) === type) {
return i;
}
}
},
_findCard: function(query, skipFail) {
var result;
if (typeof query === 'string') {
result = this._findCardUsingType(query, skipFail);
} else if (typeof(query) === 'number') { // index number
result = query;
} else {
// query is a DOM node in this case
result = this._cardStack.indexOf(query);
}
if (result > -1) {
return result;
} else if (!skipFail) {
throw new Error('Unable to find card with query:', query);
} else {
// Returning undefined explicitly so that index comparisons, like
// the one in hasCard, are correct.
return undefined;
}
},
hasCard: function(query) {
if (this._pendingPush && this._pendingPush === query) {
return true;
}
return this._findCard(query, true) > -1;
},
isVisible: function(domNode) {
return !!(domNode &&
domNode.classList.contains('center'));
},
findCardObject: function(query) {
return this._cardStack[this._findCard(query)];
},
getCurrentCardType: function() {
var result = null,
card = this._cardStack[this.activeCardIndex];
// Favor any _pendingPush value as it is about to
// become current, just waiting on an async cycle
// to finish. Otherwise use current card value.
if (this._pendingPush) {
result = this._pendingPush;
} else if (card) {
result = htmlCache.nodeToKey(card);
}
return result;
},
// Filter is an optional paramater. It is a function that returns
// true if the folder passed to it should be included in the selector
folderSelector: function(model, callback, filter) {
var self = this;
require(['value_selector'], function(ValueSelector) {
// XXX: Unified folders will require us to make sure we get the folder
// list for the account the message originates from.
if (!self.folderPrompt) {
var selectorTitle = mozL10n.get('messages-folder-select');
self.folderPrompt = new ValueSelector(selectorTitle);
}
model.latestOnce('foldersSlice', function(foldersSlice) {
var folders = foldersSlice.items;
folders.forEach(function(folder) {
var isMatch = !filter || filter(folder);
if (folder.neededForHierarchy || isMatch) {
self.folderPrompt.addToList(folder.name, folder.depth,
isMatch,
function(folder) {
return function() {
self.folderPrompt.hide();
callback(folder);
};
}(folder));
}
});
self.folderPrompt.show();
});
});
},
moveToCard: function(query, showMethod) {
this._showCard(this._findCard(query), showMethod || 'animate');
},
tellCard: function(query, what) {
var cardIndex = this._findCard(query),
domNode = this._cardStack[cardIndex];
if (!('told' in domNode)) {
console.warn('Tried to tell a card that\'s not listening!', query, what);
} else {
domNode.told(what);
}
},
/**
* Remove the card identified by its DOM node and all the cards to its right.
* Pass null to remove all of the cards! If cardDomNode passed, but there
* are no cards before it, cards.getDefaultCard is called to set up a before
* card.
*/
/* @args[
* @param[cardDomNode]{
* The DOM node that is the first card to remove; all of the cards to its
* right will also be removed. If null is passed it is understood you
* want to remove all cards.
* }
* @param[showMethod @oneof[
* @case['animate']{
* Perform an animated scrolling transition.
* }
* @case['immediate']{
* Immediately warp to the card without animation.
* }
* @case['none']{
* Remove the nodes immediately, don't do anything about the view
* position. You only want to do this if you are going to push one
* or more cards and the last card will use a transition of 'immediate'.
* }
* ]]
* @param[numCards #:optional Number]{
* The number of cards to remove. If omitted, all the cards to the right
* of this card are removed as well.
* }
* @param[nextCardSpec #:optional]{
* If a showMethod is not 'none', the card to show after removal.
* }
* @param[skipDefault #:optional Boolean]{
* Skips the default pushCard if the removal ends up with no more
* cards in the stack.
* }
* ]
*/
removeCardAndSuccessors: function(cardDomNode, showMethod, numCards,
nextCardSpec, skipDefault) {
if (!this._cardStack.length) {
return;
}
if (cardDomNode && this._cardStack.length === 1 && !skipDefault) {
// No card to go to when done, so ask for a default
// card and continue work once it exists.
return cards.pushDefaultCard(function() {
this.removeCardAndSuccessors(cardDomNode, showMethod, numCards,
nextCardSpec);
}.bind(this));
}
var firstIndex, iCard, domNode;
if (cardDomNode === undefined) {
throw new Error('undefined is not a valid card spec!');
}
else if (cardDomNode === null) {
firstIndex = 0;
// reset the z-index to 0 since we may have cards in the stack that
// adjusted the z-index (and we are definitively clearing all cards).
this._zIndex = 0;
}
else {
for (iCard = this._cardStack.length - 1; iCard >= 0; iCard--) {
domNode = this._cardStack[iCard];
if (domNode === cardDomNode) {
firstIndex = iCard;
break;
}
}
if (firstIndex === undefined) {
throw new Error('No card represented by that DOM node');
}
}
if (!numCards) {
numCards = this._cardStack.length - firstIndex;
}
if (showMethod === 'none') {
// If a 'none' remove, and the remove is for a DOM node that used
// anim-overlay, which would have increased the _zIndex when added, adjust
// the zIndex appropriately.
if (cardDomNode && cardDomNode.classList.contains('anim-overlay')) {
this._zIndex -= 10;
}
} else {
var nextCardIndex = -1;
if (nextCardSpec) {
nextCardIndex = this._findCard(nextCardSpec);
} else if (this._cardStack.length) {
nextCardIndex = Math.min(firstIndex - 1, this._cardStack.length - 1);
}
if (nextCardIndex > -1) {
this._showCard(nextCardIndex, showMethod, 'back');
}
}
// Update activeCardIndex if nodes were removed that would affect its
// value.
if (firstIndex <= this.activeCardIndex) {
this.activeCardIndex -= numCards;
if (this.activeCardIndex < -1) {
this.activeCardIndex = -1;
}
}
var deadDomNodes = this._cardStack.splice(
firstIndex, numCards);
for (iCard = 0; iCard < deadDomNodes.length; iCard++) {
domNode = deadDomNodes[iCard];
try {
domNode.die();
}
catch (ex) {
console.warn('Problem cleaning up card:', ex, '\n', ex.stack);
}
switch (showMethod) {
case 'animate':
case 'immediate': // XXX handle properly
this._animatingDeadDomNodes.push(domNode);
break;
case 'none':
domNode.parentNode.removeChild(domNode);
break;
}
}
// Reset aria-hidden attributes to handle cards visibility.
this._setScreenReaderVisibility();
},
/**
* Shortcut for removing all the cards
*/
removeAllCards: function() {
return this.removeCardAndSuccessors(null, 'none');
},
_showCard: function(cardIndex, showMethod, navDirection) {
// Do not do anything if this is a show card for the current card.
if (cardIndex === this.activeCardIndex) {
return;
}
// If the active element is one that can have focus, blur it so that the
// keyboard goes away.
var activeElement = document.activeElement;
if (activeElement && activeElement.blur) {
activeElement.blur();
}
if (cardIndex > this._cardStack.length - 1) {
// Some cards were removed, adjust.
cardIndex = this._cardStack.length - 1;
}
if (this.activeCardIndex > this._cardStack.length - 1) {
this.activeCardIndex = -1;
}
if (this.activeCardIndex === -1) {
this.activeCardIndex = cardIndex === 0 ? cardIndex : cardIndex - 1;
}
var domNode = (cardIndex !== null) ? this._cardStack[cardIndex] : null;
var beginNode = this._cardStack[this.activeCardIndex];
var endNode = this._cardStack[cardIndex];
var isForward = navDirection === 'forward';
if (this._cardStack.length === 1) {
// Reset zIndex so that it does not grow ever higher when all but
// one card are removed
this._zIndex = 0;
}
// If going forward and it is an overlay node, then do not animate the
// beginning node, it will just sit under the overlay.
if (isForward && endNode.classList.contains('anim-overlay')) {
beginNode = null;
// anim-overlays are the transitions to new layers in the stack. If
// starting a new one, it is forward movement and needs a new zIndex.
// Otherwise, going back to
this._zIndex += 10;
}
// If going back and the beginning node was an overlay, do not animate
// the end node, since it should just be hidden under the overlay.
if (beginNode && beginNode.classList.contains('anim-overlay')) {
if (isForward) {
// If a forward animation and overlay had a vertical transition,
// disable it, use normal horizontal transition.
if (showMethod !== 'immediate') {
if (beginNode.classList.contains('anim-vertical')) {
removeClass(beginNode, 'anim-vertical');
addClass(beginNode, 'disabled-anim-vertical');
} else if (beginNode.classList.contains('anim-fade')) {
removeClass(beginNode, 'anim-fade');
addClass(beginNode, 'disabled-anim-fade');
}
}
} else {
this.setStatusColor(endNode);
endNode = null;
this._zIndex -= 10;
}
}
// If the zindex is not zero, then in an overlay stack, adjust zindex
// accordingly.
if (endNode && isForward && this._zIndex) {
endNode.style.zIndex = this._zIndex;
}
var cardsNode = this._cardsNode;
// Do the status bar color work before triggering transitions, otherwise
// we lose some animation frames on the card transitions.
if (endNode) {
this.setStatusColor(endNode);
}
if (showMethod === 'immediate') {
addClass(beginNode, 'no-anim');
addClass(endNode, 'no-anim');
// make sure the reflow sees the transition is turned off.
cardsNode.clientWidth;
// explicitly clear since there will be no animation
this._eatingEventsUntilNextCard = false;
}
else if (showMethod === 'none') {
// do not set _eatingEventsUntilNextCard, but don't clear it either.
}
else {
this._transitionCount = (beginNode && endNode) ? 2 : 1;
this._eatingEventsUntilNextCard = true;
}
if (this.activeCardIndex === cardIndex) {
// same node, no transition, just bootstrapping UI.
removeClass(beginNode, 'before');
removeClass(beginNode, 'after');
addClass(beginNode, 'center');
} else if (this.activeCardIndex > cardIndex) {
// back
removeClass(beginNode, 'center');
addClass(beginNode, 'after');
removeClass(endNode, 'before');
addClass(endNode, 'center');
} else {
// forward
removeClass(beginNode, 'center');
addClass(beginNode, 'before');
removeClass(endNode, 'after');
addClass(endNode, 'center');
}
if (showMethod === 'immediate') {
// make sure the instantaneous transition is seen before we turn
// transitions back on.
cardsNode.clientWidth;
removeClass(beginNode, 'no-anim');
removeClass(endNode, 'no-anim');
this._onCardVisible(domNode);
}
// Hide toaster while active card index changed:
toaster.hide();
this.activeCardIndex = cardIndex;
// Reset aria-hidden attributes to handle cards visibility.
this._setScreenReaderVisibility();
},
_setScreenReaderVisibility: function() {
// We use aria-hidden to handle visibility instead of CSS because there are
// semi-transparent cards, such as folder picker.
this._cardStack.forEach(function(card, index) {
card.setAttribute('aria-hidden', index !== this.activeCardIndex);
}, this);
},
_onTransitionEnd: function(event) {
// Avoid other transitions except ones on cards as a whole.
if (!event.target.classList.contains('card')) {
return;
}
var activeCard = this._cardStack[this.activeCardIndex];
// If no current card, this could be initial setup from cache, no valid
// cards yet, so bail.
if (!activeCard) {
return;
}
// Multiple cards can animate, so there can be multiple transitionend
// events. Only do the end work when all have finished animating.
if (this._transitionCount > 0) {
this._transitionCount -= 1;
}
if (this._transitionCount === 0) {
if (this._eatingEventsUntilNextCard) {
this._eatingEventsUntilNextCard = false;
}
if (this._animatingDeadDomNodes.length) {
// Use a setTimeout to give the animation some space to settle.
setTimeout(function() {
this._animatingDeadDomNodes.forEach(function(domNode) {
if (domNode.parentNode) {
domNode.parentNode.removeChild(domNode);
}
});
this._animatingDeadDomNodes = [];
}.bind(this), 100);
}
// If an vertical overlay transition was was disabled, if
// current node index is an overlay, enable it again.
var endNode = activeCard;
if (endNode.classList.contains('disabled-anim-vertical')) {
removeClass(endNode, 'disabled-anim-vertical');
addClass(endNode, 'anim-vertical');
} else if (endNode.classList.contains('disabled-anim-fade')) {
removeClass(endNode, 'disabled-anim-fade');
addClass(endNode, 'anim-fade');
}
// If any action to do at the end of transition trigger now.
if (this._afterTransitionAction) {
var afterTransitionAction = this._afterTransitionAction;
this._afterTransitionAction = null;
afterTransitionAction();
}
this._onCardVisible(activeCard);
// If the card has next cards that can be preloaded, load them now.
// Use of nextCards should be balanced with startup performance.
// nextCards can result in smoother transitions to new cards on first
// navigation to that new card type, but loading the extra module may
// also compete with current card and data model performance.
var nextCards = activeCard.nextCards;
if (nextCards) {
console.log('Preloading cards: ' + nextCards);
require(nextCards.map(function(id) {
return 'cards/' + id;
}));
}
}
},
/**
* Handles final notification of card visibility in the stack.
* @param {Card} domNode the card instance.
*/
_onCardVisible: function(domNode) {
if (domNode.onCardVisible) {
domNode.onCardVisible();
}
this._emitStartupEvents(domNode.skipEmitContentEvents);
},
/**
* Handles emitting startup events used for performance tracking.
* @param {Boolean} skipEmitContentEvents if content events should be skipped
* because the card itself handles it.
*/
_emitStartupEvents: function(skipEmitContentEvents) {
if (!this._startupEventsEmitted) {
if (window.startupCacheEventsSent) {
// Cache already loaded, so at this point the content shown is wired
// to event handlers.
window.performance.mark('contentInteractive');
} else {
// Cache was not used, so only now is the chrome dom loaded.
window.performance.mark('navigationLoaded');
}
window.performance.mark('navigationInteractive');
// If a card that has a simple static content DOM, content is complete.
// Otherwise, like message_list, need backend data to call complete.
if (!skipEmitContentEvents) {
evt.emit('metrics:contentDone');
}
this._startupEventsEmitted = true;
}
},
/**
* Helper that causes (some) events targeted at our cards to be eaten until
* we get to the next card. The idea is to avoid bugs caused by the user
* still being able to click things while our cards are transitioning or
* while we are performing a (reliable) async wait before we actually initiate
* a pushCard in response to user stimulus.
*
* This is automatically triggered when performing an animated transition;
* other code should only call this in the async wait case mentioned above.
*
* For example, we don't want the user to have 2 message readers happening
* at the same time because they managed to click on a second message before
* the first reader got displayed.
*/
eatEventsUntilNextCard: function() {
this._eatingEventsUntilNextCard = true;
},
/**
* Stop eating events, presumably because eatEventsUntilNextCard was used
* as a hack for a known-fast async operation to avoid bugs (where we knew
* full well that we weren't going to show a card).
*/
stopEatingEvents: function() {
this._eatingEventsUntilNextCard = false;
},
/**
* If there are any cards on the deck right now, log an error and clear them
* all out. Our caller is strongly asserting that there should be no cards
* and the presence of any indicates a bug.
*/
assertNoCards: function() {
if (this._cardStack.length) {
throw new Error('There are ' + this._cardStack.length + ' cards but' +
' there should be ZERO');
}
}
};
return cards;
});

View file

@ -0,0 +1,155 @@
'use strict';
/*jshint browser: true */
/*global define, console, _secretDebug */
define(function(require) {
var mozL10n = require('l10n!'),
cards = require('cards');
/**
* mixin properties for cards that share similar actions around the account
* preferences.
* ASSUMES the following properties have been initialized on the object
* - this.account
* - this.identity
*/
return {
// Call this in target object's constructor to wire up the common prefs.
_bindPrefs: function(checkIntervalClassName, //sync interval select box
notifyEmailClassName, //notify email checkbox
soundOnSendClassName, //send sound on send checkbox
signatureEnabledClassName,
signatureButtonClassName) {
if (checkIntervalClassName) {
// Wire up the sync interval select box.
var checkIntervalNode = this.nodeFromClass(checkIntervalClassName),
currentInterval = this.account.syncInterval,
syncIntervalString = String(currentInterval),
extraOptions = [];
// Allow for fast sync options set via the settings_debug
// secret debugging screen.
if (typeof _secretDebug !== 'undefined' && _secretDebug.fastSync) {
extraOptions = extraOptions.concat(_secretDebug.fastSync);
}
// If existing sync option is not in the set shown in the UI,
// allow for dynamically inserting it.
var hasOption = Array.slice(checkIntervalNode.options, 0)
.some(function(option) {
return syncIntervalString === option.value;
});
if (!hasOption && extraOptions.indexOf(currentInterval) === -1) {
extraOptions.push(currentInterval);
}
// Add any extra sync interval options.
extraOptions.forEach(function(interval) {
var node = document.createElement('option'),
seconds = interval / 1000;
node.value = String(interval);
mozL10n.setAttributes(node, 'settings-check-dynamic',
{ n: seconds });
checkIntervalNode.appendChild(node);
});
checkIntervalNode.value = syncIntervalString;
checkIntervalNode.addEventListener('change',
this.onChangeSyncInterval.bind(this),
false);
}
if (notifyEmailClassName) {
var notifyMailNode = this.nodeFromClass(notifyEmailClassName);
notifyMailNode.addEventListener('click',
this.onNotifyEmailClick.bind(this),
false);
notifyMailNode.checked = this.account.notifyOnNew;
}
if (soundOnSendClassName) {
var soundOnSendNode = this.nodeFromClass(soundOnSendClassName);
soundOnSendNode.addEventListener('click',
this.onSoundOnSendClick.bind(this),
false);
soundOnSendNode.checked = this.account.playSoundOnSend;
}
if (signatureEnabledClassName) {
var signatureEnabledNode =
this.nodeFromClass(signatureEnabledClassName);
signatureEnabledNode.addEventListener('click',
this.onSignatureEnabledClick.bind(this),
false);
signatureEnabledNode.checked = !!this.identity.signatureEnabled;
}
if (signatureButtonClassName) {
this.signatureButton = this.nodeFromClass(signatureButtonClassName);
this.updateSignatureButton();
this.signatureButton.addEventListener('click',
this.onClickSignature.bind(this), false);
}
},
nodeFromClass: function(className) {
return this.getElementsByClassName(className)[0];
},
onChangeSyncInterval: function(event) {
var value = parseInt(event.target.value, 10);
console.log('sync interval changed to', value);
this.account.modifyAccount({ syncInterval: value });
},
onNotifyEmailClick: function(event) {
var checked = event.target.checked;
console.log('notifyOnNew changed to: ' + checked);
this.account.modifyAccount({ notifyOnNew: checked });
},
onSoundOnSendClick: function(event) {
var checked = event.target.checked;
console.log('playSoundOnSend changed to: ' + checked);
this.account.modifyAccount({ playSoundOnSend: checked });
},
onSignatureEnabledClick: function(event) {
var checked = event.target.checked;
console.log('signatureEnabled changed to: ' + checked);
this.identity.modifyIdentity({ signatureEnabled: checked });
},
updateSignatureButton: function() {
// Allow the text to be just whitespace, but treat it as
// empty as far as labeling is concerned.
var text = this.identity.signature || '',
isEmpty = text.trim().length === 0,
node = this.signatureButton.firstElementChild;
node.textContent = text;
node.classList.toggle('empty-placeholder', isEmpty);
if (isEmpty) {
mozL10n.setAttributes(node, 'settings-empty-signature-label');
} else {
node.removeAttribute('data-l10n-id');
}
},
onClickSignature: function(index) {
cards.pushCard(
'settings_signature', 'animate',
{
account: this.account,
index: index
},
'right');
}
};
});

View file

@ -0,0 +1,66 @@
'use strict';
define(function(require) {
var Emitter = require('evt').Emitter;
/**
* Returns an array of objects that can be fed to the 'element' module to
* create a prototype for a custom element. It takes an optional
* `templateMixins` object that is the first object to be mixed in by the
* "mixins insted of prototypes" construction that 'element' favors. This
* templateMixins should come first, as it sets up the inner DOM structure
* for an instance of the element, and needs to have been inserted before the
* other mixins in this base are applied. The templateMixins are normally
* passed to this function via a `require('template!...')` dependency. The
* 'template' loader plugin knows how to set up an object for use in this type
* of 'element' target. See the README.md in this file's directory for more
* information on the custom element approach.
* @param {Object|Array} [templateMixins] Handles the templating duties
* for the inner HTML structure of the element.
* @returns {Array} Array of objects for use in a mixin construction.
*/
return function base(templateMixins) {
// Set up the base mixin
return [
// Mix in the template first, so that its createdCallback is
// called before the other createdCallbacks, so that the
// template is there for things like l10n mixing and node
// binding inside the template.
templateMixins ? templateMixins : {},
// Wire up support for auto-node binding
require('./mixins/data-prop'),
require('./mixins/data-event'),
// Every custom element is an evt Emitter!
Emitter.prototype,
{
createdCallback: function() {
// Mark the email custom elements that are generated in this fashion
// with a specific class. This allows much more efficient query
// selector calls to grab them all vs using something like '*' and
// then filtering out the custom elements based on nodeName.
this.classList.add('email-ce');
Emitter.call(this);
},
/**
* Shortcut for triggering a DOM custom event with a detail object. Use
* this instead of evt when dealing with a custom element that wants to
* communicate with ancestor elements about an event (so therefore could
* bubble) that was based on a plain DOM event that happened inside the
* custom element.
*
* @param {String} eventName The event name
* @param {Object} detail The state info passed in event.detail
*/
emitDomEvent: function(eventName, detail) {
this.dispatchEvent(new CustomEvent(eventName, {
detail: detail
}));
}
}
];
};
});

View file

@ -0,0 +1,64 @@
'use strict';
define(function(require) {
var date = require('date'),
mozL10n = require('l10n!'),
base = require('./base');
// Set up the global time updates for all nodes.
(function() {
var updatePrettyDate = function updatePrettyDate() {
var labels = [...document.querySelectorAll('[data-time]')];
labels.forEach(label => {
date.relativeDateElement(label, label.dataset.time);
});
};
var timer = setInterval(updatePrettyDate, 60 * 1000);
function updatePrettyDateOnEvent() {
clearInterval(timer);
updatePrettyDate();
timer = setInterval(updatePrettyDate, 60 * 1000);
}
// When user changes the language, update timestamps.
mozL10n.ready(updatePrettyDateOnEvent);
// On visibility change to not hidden, update timestamps
document.addEventListener('visibilitychange', function() {
if (document && !document.hidden) {
updatePrettyDateOnEvent();
} else {
// If not visible, clear the interval to be a battery good citizen.
clearInterval(timer);
}
});
})();
return function baseCard(templateMixins) {
// Set up the base mixin
return [
base(templateMixins),
{
createdCallback: function() {
// Set up extra classes and other node information that distinguishes
// as a card. Doing this here so that by the time the createdCallback
// provided by the card so that the DOM at that point can be used for
// HTML caching purposes.
if (this.extraClasses) {
this.classList.add.apply(this.classList,
this.extraClasses);
}
this.classList.add('card');
},
batchAddClass: function(searchClass, classToAdd) {
var nodes = this.getElementsByClassName(searchClass);
for (var i = 0; i < nodes.length; i++) {
nodes[i].classList.add(classToAdd);
}
}
}
];
};
});

View file

@ -0,0 +1,9 @@
<li class="cmp-attachment-item">
<span class="cmp-attachment-icon" role="presentation"></span>
<span class="cmp-attachment-fileinfo">
<span dir="auto" class="cmp-attachment-filename"></span>
<span class="cmp-attachment-filesize"></span>
</span>
<span class="cmp-attachment-remove" role="button"
data-l10n-id="compose-attachment-remove"></span>
</li>

View file

@ -0,0 +1,542 @@
'use strict';
define(function(require) {
var GestureDetector = require('shared/js/gesture_detector'),
itemTemplate = require('tmpl!./autocomplete_item.html'),
mozL10n = require('l10n!'),
regExpEscape = require('regExpEscape'),
transitionEnd = require('transition_end'),
validTypes = {
email: true,
text: true
};
/**
* Converts a text string into a span and some text nodes that are placed
* as the children of node. The span contains the match for the queryRegExp.
* @param {RegExp} queryRegExp The regexp whose match is highlighted via a
* span in the target text.
* @param {String} text The target text to find the match.
* @param {Element} node The element that will display the matched text.
*/
function formatMatchString(queryRegExp, text, node) {
// Just highlight the first match.
var match = queryRegExp.exec(text);
if (match) {
var index = match.index,
endIndex = index + queryRegExp.source.length;
var startString = text.substring(0, index);
if (startString) {
node.appendChild(document.createTextNode(startString));
}
var matchString = text.substring(index, endIndex);
if (matchString) {
var span = document.createElement('span');
span.classList.add('highlight');
span.textContent = matchString;
node.appendChild(span);
}
var endString = text.substring(endIndex);
if (endString) {
node.appendChild(document.createTextNode(endString));
}
} else {
node.textContent = text;
}
}
/**
* Given a node, if it or an ancestor has the CSS className, return the node
* with that className. Returns null if not a match.
* @param {Node} node
* @return {Node}
*/
function findAncestorWithClass(node, className) {
for (; node; node = node.parentNode) {
if (node.classList && node.classList.contains(className)) {
return node;
}
}
return null;
}
/**
* Given a node, if it or an ancestor is an autocomplete item in the
* autocomplete list, return the autcomplete item node. Returns null if not a
* match.
* @param {Node} node
* @return {Node}
*/
function getAutocompleteItem(node) {
return findAncestorWithClass(node, 'cmp-autocomplete-item');
}
/**
* Autocomplete custom element that contains a list of elements that show the
* autocomplete matches.
*
* This custom element relies on a CSS class-based API to bind to nodes outside
* of this autocomplete. The CSS/HTML structure expected by this autocomplete:
*
* - An element with the CSS class of "cmp-autocomplete-origin" that is the the
* master scroll area for the view. It should be an ancestor node to the
* autocomplete and also contain the 'cmp-autocomplete-input-list' element that
* has all the input elements. This element is used as the "origin" for the
* style.top placement of the autocomplete list.
*
* - An element that holds all the input fields, with the CSS class of
* "cmp-autocomplete-input-list". That inputArea will be translateY'd to make
* sure the current input focus is visible at the top of the view.
*
* - Individual rows that hold an autocomplete must specify a
* "cmp-autocomplete-input-label" CSS class, so that for a given input, that
* parent can be found to be marked as "active". This allows just that row to
* change its alignment so its label and + icon are aligned at the bottom with
* the text input.
*
* The autocomplete custom element also expects a "data-source" HTML attribute
* to be set on it, which indicates the module ID to use as the data source for
* the matches.
*
* The owner of this custom element instance (typically a view card), should
* override the getExistingEntries() method on the instance to return the
* current entries, to avoid dupes showing up in subsequent autocomplete
* matches.
*/
return [
require('../base')(),
{
createdCallback: function() {
this.lastTranslateY = 0;
this.inputAreaTranslated = Promise.resolve();
this.runQuery = this.runQuery.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onDocumentEvent = this.onDocumentEvent.bind(this);
this.onInput = this.onInput.bind(this);
// Find (when cached HTML) or create list to hold the autocomplete matches
// Put the list inside the autocomplete element instead of off the
// document, as it should be within a given UI card, in case in-app
// overlay UIs like confirm dialogs need to be shown.
this.list = this.querySelector('.cmp-autocomplete-list');
if (!this.list) {
this.list = document.createElement('ul');
this.list.classList.add('cmp-autocomplete-list');
this.appendChild(this.list);
}
this.list.addEventListener('keydown', this.onMatchKeyDown.bind(this));
this.list.addEventListener('click', this.onMatchClick.bind(this));
this.list.innerHTML = '';
this.hideList();
// Find parent that is scrollable and is the origin for measurements.
var parentNode = this;
while((parentNode = parentNode.parentNode)) {
if (parentNode.classList.contains('cmp-autocomplete-origin')) {
this.originNode = parentNode;
break;
}
}
// Hold on to the element that holds all the input fields, to use it for
// sizing and setting scroll behavior amongst the inputs while the
// autocomplete is active.
this.inputArea = this.originNode
.querySelector('.cmp-autocomplete-input-list');
// Listen for transitionend events on the inputArea, since translateY with
// a transition is used to move the input to the top of the view. Want to
// wait for the transition to end before showing autocomplete results.
// This allows the user to better track where the addresses have gone,
// and feels smoother.
transitionEnd(this.inputArea, this._translateYTransitionEnd.bind(this));
// Bind to events triggered by input field children. Need to mark the
// elements with an autocomplete class, and do not want to target just
// any input in the inputArea, so using explicit per-element bindings
// instead of event delegation.
var nodes = this.inputArea.querySelectorAll('input');
Array.from(nodes, function(inputNode) {
if (!validTypes.hasOwnProperty(inputNode.type)) {
return;
}
// Add a class to the input to mark it as participating in autocomplete.
// This is used to know if taps in them should be disregarded for
// onDocumentEvent purposes.
inputNode.classList.add('cmp-autocomplete-input');
inputNode.addEventListener('focus', this.onFocus);
inputNode.addEventListener('input', this.onInput);
}, this);
// Use a GestureDetector to know when user tries to scroll the inputArea
// element to see addresses that have been scrolled out of the way for
// autocomplete maximum real estate. No need to clean this up later, the
// destruction of the card using the autocomplete is enough: the watched
// element is destroyed along with the autocomplete.
this.detector = new GestureDetector(this.inputArea);
this.inputArea.addEventListener('pan', (event) => {
this.hide();
});
this.detector.startDetecting();
// Set up data source.
var dataSourceId = this.dataset.source;
if (dataSourceId) {
this.bindDataSource(dataSourceId);
}
},
/**
* Custom element callback when element is placed in the document.
*/
attachedCallback: function() {
this.onResize = this.onResize.bind(this);
window.addEventListener('resize', this.onResize, false);
// Explicitly want to listen during the capture phase, since most code
// listens in bubble phase, and may stopPropagation. For autocomplete
// hiding though we always want it to disappear if event is outside of
// the interesting areas.
document.addEventListener('click', this.onDocumentEvent, true);
document.addEventListener('focus', this.onDocumentEvent, true);
},
/**
* Custom element callback when element is removed from the document.
*/
detachedCallback: function() {
window.removeEventListener('resize', this.onResize, false);
document.removeEventListener('click', this.onDocumentClick, true);
document.addEventListener('focus', this.onDocumentEvent, true);
},
/**
* Triggered by input focus events.
*/
onFocus: function(event) {
this.inputNode = event.target;
// If existing label node, be sure to remove the active class from it. In
// cases where the focus goes from one input to the other, hide() does not
// run to clear this state.
if (this.inputLabelNode) {
this.inputLabelNode.classList.remove('cmp-autocomplete-active');
}
this.inputLabelNode = findAncestorWithClass(this.inputNode,
'cmp-autocomplete-input-label');
this.inputLabelNode.classList.add('cmp-autocomplete-active');
this.query = this.inputNode.value.trim();
this.runThrottledQuery();
},
/**
* Triggered by any click or focus in the document. Used to determine if the
* autocomplete should be hidden.
*/
onDocumentEvent: function(event) {
var target = event.target;
// If no classList, then it is not an element, and should be discarded.
if (!target.classList) {
this.hide();
return;
}
// If an autocomplete input, do not do anything, handled by other code in
// this module.
if (target.classList.contains('cmp-autocomplete-input')) {
return;
}
// Also ignore if it is part of an autocomplete-item.
if (getAutocompleteItem(target)) {
return;
}
this.hide();
},
/**
* Triggered by input element input events.
*/
onInput: function(event) {
this.query = this.inputNode.value.trim();
this.runThrottledQuery();
},
/**
* Handles window resizes. Could be triggered by keyboard size changes.
*/
onResize: function(event) {
if (this.inputNode && !this.isHidden) {
this.positionElements();
}
},
onMatchKeyDown: function(event) {
if (event.key === 'Enter' && !this.isHidden) {
this.onMatchClick(event);
}
},
/**
* Handles taps inside the autocomplete. The tap could occur outside a match
* element, in which case means the autocomplete should just close.
*/
onMatchClick: function(event) {
var node = getAutocompleteItem(event.originalTarget);
if (node && node.match) {
event.preventDefault();
this.dispatchEvent(new CustomEvent('autocompleteSelected', {
detail: {
match: node.match,
inputNode: this.inputNode
}
}));
}
},
/**
* Loads the dataSource module.
* @param {String} dataSourceId The module ID for the data source.
*/
bindDataSource: function(dataSourceId) {
require([dataSourceId], (dataSource) => {
this.dataSource = dataSource;
if (this.query) {
this.runThrottledQuery();
}
});
},
/**
* Called to trigger finding matches from the dataSource. Set on a timeout
* to avoid multiple fast keystrokes from triggering too many rapid queries.
*/
runThrottledQuery: function() {
if (!this.query || !this.dataSource) {
this.list.innerHTML = '';
this.positionAndHide();
return;
}
if (!this.timedQueryId) {
// The choice of delay tries to balance sending too many queries to the
// contacts API vs giving a feeling of responsiveness for the reaction
// of the autocomplete. This balance may be affected by the total number
// of contacts.
this.timedQueryId = setTimeout(this.runQuery, 350);
}
},
/**
* The owner of this autocomplete instance should set this value if they
* have extra spacing around the input that should be maintained for better
* visual display, and to avoid small scroll janks on initial positioning
* of the input element if it needs to be scrolled to the top of the view.
*/
verticalSpace: 0,
/**
* This method can be overriden by the owner of the autocomplete to return
* existing entries already attached to the email. The result should be an
* array of objects that have "name" and "address" properties.
* @return {Array} Array of existing entries.
*/
getExistingEntries: function() {
return [];
},
/**
* Runs the query to find matches from the dataSource.
*/
runQuery: function() {
this.timedQueryId = 0;
this.dataSource(this.query).then((result) => {
// Protect against old or out of date queries
if (result.query !== this.query) {
return;
}
var existingEntries = this.getExistingEntries();
var contacts = result.contacts;
this.list.innerHTML = '';
var queryRegExp = new RegExp(regExpEscape(this.query), 'i');
contacts.forEach((contact) => {
// Only care about contacts with an email address(es).
var emails = contact.email;
if (!emails || !emails.length) {
return;
}
// Contact name could be an array, just use the first one.
var name = contact.name;
if (Array.isArray(name)) {
name = name[0];
}
// Emails are an array, each one should get its own entry in the
// autocomplete.
emails.forEach((email) => {
var address = email.value;
// Do not add it if the email address is already an entry.
var hasExisting = existingEntries.some(function(entry) {
return entry.address === address;
});
if (hasExisting) {
return;
}
var node = itemTemplate.cloneNode(true),
detailsNode = node
.querySelector('.cmp-autocomplete-item-details'),
nameNode = node.querySelector('.cmp-autocomplete-name'),
emailNode = node.querySelector('.cmp-autocomplete-email');
// Set the ariaLabel for the match, for screen reader use only.
mozL10n.setAttributes(detailsNode, 'compose-autocomplete-match',
{ name: name, address: address });
formatMatchString(queryRegExp,
name || address,
nameNode);
formatMatchString(queryRegExp, address|| '', emailNode);
// Store the match data on the node for ease of access when items
// are tapped.
var match = {
name: name,
address: address
};
node.match = match;
this.list.appendChild(node);
});
});
// Always reshow on a query, since each keystroke has the possibility of
// breaking the input field to a new line.
if (this.list.children.length) {
this.show();
} else {
this.positionAndHide();
}
});
},
/**
* Positions the input area to the top of the view and sizes the list to
* take up as much view space as possible.
*/
positionElements: function() {
// Always calculate the originTop since the whole card that contains the
// origin could be scrolled for some reason, even by an accident.
var originTop = this.originNode.getBoundingClientRect().top;
// Want to use the input element, and not a parent of it, for placement of
// the autocomplete, since the input element can move to different lines
// as typing occurs, where the parent's origin would not change, and would
// be a bad reference point to use.
var inputRect = this.inputNode.getBoundingClientRect();
// Scroll the area so that the input item is now at the top of the
// address area.
var translateY = inputRect.top - originTop +
this.lastTranslateY -
this.verticalSpace;
if (translateY !== this.lastTranslateY) {
this.lastTranslateY = translateY;
// Create a new promise to track the end of the inputArea translation.
this.inputAreaTranslated = new Promise((resolve) => {
// No need to track reject, only resolve/ok is followed in the
// transitionEnd machinery.
this._inputAreaResolve = resolve;
});
this.inputArea.style.transform = 'translateY(-' + translateY + 'px)';
}
return this.inputAreaTranslated.then(() => {
this.listTop = inputRect.height + (2 * this.verticalSpace);
this.list.style.top = this.listTop + 'px';
this.list.style.height = (window.innerHeight - this.listTop -
originTop) + 'px';
// Set the list scrollTop to the top because previous listing could have
// had it scrolled.
this.list.scrollTop = 0;
});
},
_translateYTransitionEnd: function(event) {
if (this._inputAreaResolve) {
this._inputAreaResolve();
this._inputAreaResolve = null;
}
},
show: function() {
// The originNode should be at the top of its scroll area, and set a
// CSS class on it that indicates the origin should not show scroll
// bars or allow scrolling, to avoid overlapping, confusing double
// scrollbars.
this.originNode.scrollTop = 0;
this.originNode.classList.add('cmp-autocomplete-noscroll');
this.positionElements().then(() => {
this.list.classList.remove('collapsed');
});
},
hide: function() {
this.hideList();
if (this.inputLabelNode) {
this.inputLabelNode.classList.remove('cmp-autocomplete-active');
}
this.inputArea.style.transform = 'translateY(0px)';
this.lastTranslateY = 0;
this.originNode.classList.remove('cmp-autocomplete-noscroll');
},
hideList: function() {
this.list.classList.add('collapsed');
},
/**
* Makes sure the input element is at the top of the view, but do not show
* the autocomplete list because there are no matches to show.
*/
positionAndHide: function() {
this.positionElements().then(() => {
this.hideList();
});
},
get isHidden() {
return this.list.classList.contains('collapsed');
}
}
];
});

View file

@ -0,0 +1,11 @@
<li class="cmp-autocomplete-item">
<!-- Using an a tag so that screen reader properly activates a selection
when tapped. Using a div meant the ul element got the click. To get proper full bleed on the active/selected color, need to use another div inside
the a tag to get that while keeping the margins on text and border. -->
<a class="cmp-autocomplete-item-link" href="#">
<div class="cmp-autocomplete-item-details">
<span class="cmp-autocomplete-name" dir="auto" aria-hidden="true"></span>
<span class="cmp-autocomplete-email" dir="auto" aria-hidden="true"></span>
</div>
</a>
</li>

View file

@ -0,0 +1,53 @@
'use strict';
define(function(require) {
var contacts = navigator.mozContacts;
function makeResult(query, contacts) {
return {
query: query,
contacts: contacts
};
}
/**
* Finds matches for the given query in the name and email for known people.
* Returns a promise that resolves to an object that has the query and a
* "match" array of mozContact entries. The match array will be a zero length
* array for no matches. See this bug about expanding the data source beyond
* contacts matches:
* https://bugzilla.mozilla.org/show_bug.cgi?id=1188710
* @param {String} query the name/email query to use.
* @return {Promise}
*/
return function match(query) {
return new Promise(function(resolve, reject) {
if (!contacts || !query) {
return resolve(makeResult(query, []));
}
var request = contacts.find({
filterBy: ['email', 'name', 'givenName', 'familyName'],
filterOp: 'startsWith',
// Do not overwhelm the user with choice, limit the results. It is likely
// easier for the user to keep typing to narrow the matches than scroll
// through a big list, and a small bound on the results will result in
// better performance, even when generating the UI for the matches.
filterLimit: 30,
filterValue: query
});
request.onsuccess = function () {
resolve(makeResult(query, this.result));
};
// It is OK to just eat errors, no need to show them in the UI, but let
// the log know for debugging purposes.
request.onerror = function (err) {
console.error('autocomplete_source contacts.find error: ' + err);
resolve(makeResult(query, []));
};
});
};
});

View file

@ -0,0 +1,13 @@
<form class="cmp-contact-menu" role="dialog" data-type="action">
  <header></header>
<menu>
<button class="cmp-contact-menu-edit" data-l10n-id="message-edit-menu-edit">
</button>
  <button class="cmp-contact-menu-delete" data-l10n-id="message-edit-menu-delete">
</button>
<span class="last-button-container">
<button class="cmp-contact-menu-cancel" data-l10n-id="message-multiedit-cancel">
</button>
</span>
</menu>
</form>

View file

@ -0,0 +1,9 @@
<form role="dialog" class="cmp-draft-menu" data-type="action">
<menu>
<button id="cmp-draft-save" data-l10n-id="compose-draft-save"></button>
<button id="cmp-draft-discard" class="danger" data-l10n-id="compose-delete-confirm"></button>
<span class="last-button-container">
<button id="cmp-draft-cancel" data-l10n-id="message-multiedit-cancel"></button>
</span>
</menu>
</form>

View file

@ -0,0 +1,11 @@
<form role="dialog" data-type="confirm">
<section>
<h1 data-l10n-id="compose-invalid-addresses-title"></h1>
<p data-l10n-id="compose-invalid-addresses-description"></p>
</section>
<menu>
<button id="cmp-confirm-invalid-addresses"
class="confirm-dialog-ok full recommend"
data-l10n-id="dialog-button-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,4 @@
<div class="cmp-peep-bubble peep-bubble" dir="auto" role="button">
<span class="cmp-peep-name"></span>
<span class="cmp-peep-address collapsed"></span>
</div>

View file

@ -0,0 +1,119 @@
<section class="cmp-compose-header" role="region" data-statuscolor="default">
<header data-prop="headerNode">
<a role="button" data-event="click:onBack" href="#" class="cmp-back-btn"
data-l10n-id="back-button">
<span class="icon icon-back"></span>
</a>
<menu type="toolbar">
<a role="button" href="#" data-event="click:onAttachmentAdd"
class="cmp-attachment-btn" data-l10n-id="compose-attachment-button">
<span class="icon icon-attachment"></span>
</a>
<a role="button" data-prop="sendButton" data-event="click:onSend"
href="#" class="cmp-send-btn" data-l10n-id="compose-send-button">
<span class="icon icon-send"></span>
</a>
</menu>
<h1 class="cmp-compose-header-label"
data-l10n-id="compose-new-header"></h1>
</header>
</section>
<div data-prop="scrollContainer"
class="scrollregion-below-header cmp-autocomplete-origin">
<div data-prop="addrBar">
<div class="cmp-autocomplete-input-container">
<div class="cmp-autocomplete-input-list">
<div data-prop="firstEnvelopeLine"
data-event="click:onContainerClick"
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
role="group" data-l10n-id="to-group">
<span class="cmp-to-label cmp-addr-label"
data-l10n-id="compose-to" aria-hidden="true"></span>
<div class="cmp-to-container cmp-addr-container">
<div class="cmp-bubble-container">
<input data-prop="toNode"
data-event="keydown:onAddressKeydown,input:onAddressInput"
dir="auto"
class="cmp-to-text cmp-addr-text" type="email" />
</div>
</div>
<div data-event="click:onContactAdd"
class="cmp-to-add cmp-contact-add"
role="button"
data-l10n-id="compose-contact-add-recipient"></div>
</div>
<!-- XXX: spec calls for showing cc/bcc merged until selected,
but there is also the case where replying itself might need
to expand, so we are deferring that feature -->
<div data-event="click:onContainerClick"
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
role="group" data-l10n-id="cc-group">
<span class="cmp-cc-label cmp-addr-label"
data-l10n-id="compose-cc" aria-hidden="true"></span>
<div class="cmp-cc-container cmp-addr-container">
<div class="cmp-bubble-container">
<input data-prop="ccNode"
data-event="keydown:onAddressKeydown,input:onAddressInput"
dir="auto"
class="cmp-cc-text cmp-addr-text" type="email" />
</div>
</div>
<div data-event="click:onContactAdd"
class="cmp-cc-add cmp-contact-add"
role="button"
data-l10n-id="compose-contact-add-recipient"></div>
</div>
<div data-event="click:onContainerClick"
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
role="group" data-l10n-id="bcc-group">
<span class="cmp-bcc-label cmp-addr-label"
data-l10n-id="compose-bcc" aria-hidden="true"></span>
<div class="cmp-bcc-container cmp-addr-container">
<div class="cmp-bubble-container">
<input data-prop="bccNode"
data-event="keydown:onAddressKeydown,input:onAddressInput"
dir="auto"
class="cmp-bcc-text cmp-addr-text" type="email" />
</div>
</div>
<div data-event="click:onContactAdd"
class="cmp-bcc-add cmp-contact-add"
role="button"
data-l10n-id="compose-contact-add-recipient"></div>
</div>
</div>
</div>
<div class="cmp-envelope-line cmp-subject">
<span class="cmp-subject-label"
data-l10n-id="compose-subject" aria-hidden="true"></span>
<input data-prop="subjectNode"
dir="auto"
class="cmp-subject-text" type="text" data-l10n-id="subject" />
</div>
<div data-prop="errorMessage" class="cmp-error-message collapsed"></div>
<div data-prop="attachmentTotal"
class="cmp-envelope-line cmp-attachment-total collapsed"
aria-hidden="true">
<span data-prop="attachmentLabel"
class="cmp-attachment-label cmp-addr-label"
data-l10n-id="compose-attachments[zero]"></span>
<span data-prop="attachmentsSize" class="cmp-attachment-size"></span>
</div>
<ul data-prop="attachmentsContainer" class="cmp-attachment-container">
</ul>
</div>
<div data-prop="textBodyNode" class="cmp-body-text" contenteditable="true" role="textbox" aria-multiline="true"
data-l10n-id="compose-text"></div>
<div data-prop="htmlBodyContainer"
data-event="click:_focusEditorWithCursorAtEnd"
dir="auto"
class="cmp-body-html"
data-l10n-id="message-body-container">
</div>
<!-- Autocomplete at the bottom since it should have the highest z order
placement, so that the autocomplete matches are visible. -->
<cmp-autocomplete data-prop="autocomplete"
data-event="autocompleteSelected"
data-source="cards/cmp/autocomplete_source">
</cmp-autocomplete>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
<div class="card-confirm-dialog card">
<form data-statuscolor="background"
role="dialog" data-type="confirm" class="collapsed confirm-dialog-form">
<section>
<h1 data-l10n-id="confirm-dialog-title"></h1>
<p class="confirm-dialog-message"></p>
</section>
<menu>
<button class="confirm-dialog-cancel" data-l10n-id="message-multiedit-cancel"></button>
<button class="confirm-dialog-ok recommend" data-l10n-id="dialog-button-ok"></button>
</menu>
</form>
</div>

View file

@ -0,0 +1,67 @@
'use strict';
define(function(require) {
var cards = require('cards');
return [
require('./base_card')(require('template!./confirm_dialog.html')),
{
onArgs: function(args) {
var dialogBodyNode = args.dialogBodyNode,
confirm = args.confirm,
cancel = args.cancel,
callback = args.callback;
if (dialogBodyNode) {
this.appendChild(dialogBodyNode);
} else {
// If no dialogBodyNode passed in, use the default form display, and
// configure the confirm/cancel hand, for the simple way of handling
// confirm dialogs.
dialogBodyNode = this.querySelector('.confirm-dialog-form');
dialogBodyNode.querySelector('.confirm-dialog-message')
.textContent = args.message;
dialogBodyNode.classList.remove('collapsed');
confirm = {
handler: function() {
callback(true);
}
};
cancel = {
handler: function() {
callback(false);
}
};
}
// Wire up the event handling
dialogBodyNode.addEventListener('submit', function(evt) {
evt.preventDefault();
evt.stopPropagation();
this.hide();
var target = evt.explicitOriginalTarget,
targetId = target.id,
isOk = target.classList.contains('confirm-dialog-ok'),
isCancel = target.classList.contains('confirm-dialog-cancel');
if ((isOk || targetId === confirm.id) && confirm.handler) {
confirm.handler();
} else if ((isCancel || targetId === cancel.id) && cancel.handler) {
cancel.handler();
}
}.bind(this));
},
hide: function() {
cards.removeCardAndSuccessors(this, 'immediate', 1, null, true);
},
die: function() {
}
}
];
});

View file

@ -0,0 +1,75 @@
'use strict';
define(function(require) {
return {
_bindEditor: function(textNode) {
this._editorNode = textNode;
// Prevent pastes of rich HTML, since it has encoding, security and
// sanitation issues.
textNode.addEventListener('paste', function(event) {
event.preventDefault();
var text = event.clipboardData.getData('text/plain');
// Only insert if text. If no text, the execCommand fails with an
// error.
if (text) {
document.execCommand('insertText', false, text);
}
});
},
/**
* Inserts an email into the contenteditable element
*/
populateEditor: function(value) {
var lines = value.split('\n');
var frag = document.createDocumentFragment();
for (var i = 0, len = lines.length; i < len; i++) {
if (i) {
frag.appendChild(document.createElement('br'));
}
if (lines[i]) {
frag.appendChild(document.createTextNode(lines[i]));
}
}
// Need at least one text node for tapping and keyboard display to work.
if (!frag.childNodes.length) {
frag.appendChild(document.createTextNode(''));
}
this._editorNode.appendChild(frag);
},
/**
* Gets the raw value from a contenteditable div
*/
fromEditor: function(value) {
var content = '';
var len = this._editorNode.childNodes.length;
for (var i = 0; i < len; i++) {
var node = this._editorNode.childNodes[i];
if (node.nodeName === 'BR' &&
// Gecko's contenteditable implementation likes to create a
// synthetic trailing BR with type="_moz". We do not like/need
// this synthetic BR, so we filter it out. Check out
// nsTextEditRules::CreateTrailingBRIfNeeded to find out where it
// comes from.
node.getAttribute('type') !== '_moz') {
content += '\n';
} else {
content += node.textContent;
}
}
return content;
}
};
});

View file

@ -0,0 +1,4 @@
<a class="fld-account-item">
<span class="selected-indicator"></span>
<span class="fld-account-name"></span>
</a>

View file

@ -0,0 +1,5 @@
<a class="fld-folder-item">
<span class="selected-indicator"></span>
<span dir="auto" class="fld-folder-name"></span>
<span class="fld-folder-unread"></span>
</a>

View file

@ -0,0 +1,71 @@
<!-- Our card does not have a header of its own; it reuses the message_list's
header. We want to steal clicks on its menu button too, hence this node
and fld-header-back which is a transparent button that overlays the menu
button. -->
<div class="fld-header-placeholder" data-statuscolor="default">
<!-- Unlike a generic back button that navigates to a different screen, folder
picker header button triggers the folders and settings overlay closure.
Thus the screen reader user requires more context as to what activating
the button would do. -->
<button aria-expanded="true" aria-controls="cards-folder-picker"
data-l10n-id="message-list-menu"
data-event="click:_closeCard"
class="fld-header-back"></button>
</div>
<!-- Backing semi-opaque layer for everything below the header so that
anything that the folder drawer does not cover looks inactive-ish.
Clicking on this makes the drawer go away. -->
<div data-event="click:_closeCard" class="fld-shield"></div>
<!-- This exists to clip fld-content so that when it animates in/out using
translateY it doesn't paint over-top of the header. -->
<div class="fld-content-container">
<!-- This is the actual drawer thing that slides up/down using translateY.
-->
<div class="fld-content">
<!-- Scrollable container holds everything but the non-scrolling settings
button. -->
<div class="fld-acct-scrollouter">
<!-- Combo-box style widget that shows the current account name when
collapsed and the fld-acct-header-account-header label when
expanded. The actual items live in fld-accountlist-container
for animation reasons; see below. -->
<a data-prop="accountHeader"
data-event="click:toggleAccounts"
class="fld-acct-header closed" role="region">
<!--
The &nbsp; is on purpose, so that initial height measurement of the
element be correct even before this element has real content.
-->
<span class="fld-acct-header-account-label">&nbsp;</span>
<span class="fld-acct-header-account-header"
data-l10n-id="drawer-accounts-header"></span>
<span class="fld-account-switch-arrow"></span>
</a>
<!-- Exists to clip fld-acct-container which uses translateY to hide the
list of accounts in negative-space until the account list is
opened. This has an explicit height style set based on whether the
list of accounts is displayed or not because fld-acct-scrollinner
will always have the same height for layout purposes because the
translateY hiding does not affect layout. -->
<div data-prop="fldAcctScrollInner" class="fld-acct-scrollinner">
<!-- The list of all accounts and the folders for the current account.
As noted above, translateY is used to hide the list of accounts
when we don't want to be displaying it. -->
<div data-prop="fldAcctContainer" class="fld-acct-container">
<!-- The list of accounts -->
<div data-prop="accountListContainer"
class="fld-accountlist-container">
</div>
<!-- The list of folders for the current account. -->
<div data-prop="foldersContainer"
class="fld-folders-container">
</div>
</div>
</div>
</div>
<!-- settings button; always present; does not scroll -->
<a data-event="click:onShowSettings" class="fld-nav-toolbar bottom-toolbar">
<span class="fld-settings-link" data-l10n-id="drawer-settings-link"></span>
</a>
</div>
</div>

View file

@ -0,0 +1,364 @@
/*global define */
'use strict';
define(function(require) {
var containerListen = require('container_listen'),
fldFolderItemNode = require('tmpl!./fld/folder_item.html'),
fldAccountItemNode = require('tmpl!./fld/account_item.html'),
FOLDER_DEPTH_CLASSES = require('folder_depth_classes'),
cards = require('cards'),
evt = require('evt'),
transitionEnd = require('transition_end');
require('css!style/folder_cards');
return [
require('./base_card')(require('template!./folder_picker.html')),
{
createdCallback: function() {
containerListen(this.foldersContainer, 'click',
this.onClickFolder.bind(this));
containerListen(this.accountListContainer, 'click',
this.onClickAccount.bind(this));
transitionEnd(this, this.onTransitionEnd.bind(this));
this.updateAccount = this.updateAccount.bind(this);
},
onArgs: function(args) {
var model = this.model = args.model;
model.latest('account', this.updateAccount);
// If more than one account, need to show the account dropdown
var accountCount = model.getAccountCount();
if (accountCount > 1) {
this.classList.remove('one-account');
// Set up size needed to handle translation animation for showing
// accounts later.
this.currentAccountContainerHeight = this.accountHeader
.getBoundingClientRect().height *
accountCount;
this.hideAccounts();
}
this.acctsSlice = model.api.viewAccounts(false);
this.acctsSlice.onsplice = this.onAccountsSplice.bind(this);
this.acctsSlice.onchange = this.onAccountsChange.bind(this);
},
extraClasses: ['anim-vertical', 'anim-overlay', 'one-account'],
onShowSettings: function(event) {
cards.pushCard('settings_main', 'animate');
},
/**
* Clicking a different account changes the list of folders displayed. We
* then trigger a select of the inbox for that account because otherwise
* things get permutationally complex.
*/
updateAccount: function(account) {
var oldAccount = this.curAccount;
this.mostRecentSyncTimestamp = 0;
if (oldAccount !== account) {
this.foldersContainer.innerHTML = '';
this.model.latestOnce('folder', function(folder) {
this.curAccount = account;
// - DOM!
// update header
this.querySelector('.fld-acct-header-account-label')
.textContent = account.name;
// If no current folder, means this is the first startup, do some
// work to populate the
if (!this.curFolder) {
this.curFolder = folder;
}
// Clean up any old bindings.
if (this.foldersSlice) {
this.foldersSlice.onsplice = null;
this.foldersSlice.onchange = null;
}
this.foldersSlice = this.model.foldersSlice;
// since the slice is already populated, generate a fake notification
this.onFoldersSplice(0, 0, this.foldersSlice.items, true, false);
// Listen for changes in the foldersSlice.
// TODO: perhaps slices should implement an event listener
// interface vs. only allowing one handler. This is slightly
// dangerous in that other cards may access model.foldersSlice
// and could decide to set these handlers, wiping these ones
// out. However, so far folder_picker is the only one that cares
// about these dynamic updates.
this.foldersSlice.onsplice = this.onFoldersSplice.bind(this);
}.bind(this));
}
},
/**
* Tapping a different account will jump to the inbox for that
* account, but only do the jump if a new account selection,
* and only after hiding the folder_picker.
*/
onClickAccount: function(accountNode, event) {
var oldAccountId = this.curAccount.id,
accountId = accountNode.account.id;
this.curAccount = accountNode.account;
if (oldAccountId !== accountId) {
// Store the ID and wait for the closing animation to finish
// for the card before switching accounts, so that the
// animations are smoother and have fewer jumps.
this._waitingAccountId = accountId;
this._closeCard();
}
},
toggleAccounts: function() {
// During initial setup, to get the sizes right for animation later,
// the translateY was modified. During that time, do not want animation,
// but now for toggling the display/hiding based on user action, enable
// it.
var hadAnimated = this.fldAcctContainer.classList.contains('animated');
if (!hadAnimated) {
this.fldAcctContainer.classList.add('animated');
// Trigger acknowledgement of the transition by causing a reflow
// for account container element.
this.fldAcctContainer.clientWidth;
}
if (this.accountHeader.classList.contains('closed')) {
this.showAccounts();
} else {
this.hideAccounts();
}
},
/**
* Use a translateY transition to show accounts. But to do that correctly,
* need to use the height of the account listing. The scroll inner needs
* to be updated too, so that it does not cut off some of the folders.
*/
showAccounts: function() {
var height = this.currentAccountContainerHeight;
this.fldAcctScrollInner.style.height = (height +
this.foldersContainer.getBoundingClientRect().height) + 'px';
this.fldAcctContainer.style.transform = 'translateY(0)';
this.accountHeader.classList.remove('closed');
},
/**
* Use a translateY transition to hide accounts. But to do that correctly,
* need to use the height of the account listing. The scroll inner needs to
* be updated too, so that it form-fits over the folder list.
*/
hideAccounts: function() {
var foldersHeight = this.foldersContainer.getBoundingClientRect().height;
if (foldersHeight) {
this.fldAcctScrollInner.style.height = foldersHeight + 'px';
}
this.fldAcctContainer.style.transform = 'translateY(-' +
this.currentAccountContainerHeight +
'px)';
this.accountHeader.classList.add('closed');
},
/**
* Used to populate the account list.
*/
onAccountsSplice: function(index, howMany, addedItems,
requested, moreExpected) {
var accountListContainer = this.accountListContainer;
// Note! We get called before the splice() is run on this.acctsSlice.items
var postSliceCount = this.acctsSlice.items.length +
addedItems.length - howMany;
this.classList.toggle('one-account', postSliceCount <= 1);
// Clear out accounts that have been removed
var account;
if (howMany) {
for (var i = index + howMany - 1; i >= index; i--) {
account = this.acctsSlice.items[i];
if (account.element) {
accountListContainer.removeChild(account.element);
}
}
}
var insertBuddy = (index >= accountListContainer.childElementCount) ?
null : accountListContainer.children[index];
// Add DOM for each account
addedItems.forEach(function(account) {
var accountNode = account.element =
fldAccountItemNode.cloneNode(true);
accountNode.account = account;
this.updateAccountDom(account, true);
accountListContainer.insertBefore(accountNode, insertBuddy);
}.bind(this));
// Use the accountHeader as a unit of height and multiple
// by the number of children, to get total height needed for
// all accounts. Doing this instead of measuring the height
// for accountListContainer, since to get a good measurement
// needs to not be display none, which could introduce a flash
// of seeing the element.
this.currentAccountContainerHeight = this.accountHeader
.getBoundingClientRect().height *
accountListContainer.children.length;
// Recalculate heights needed to properly hide the accounts.
this.hideAccounts();
},
onAccountsChange: function(account) {
this.updateAccountDom(account, false);
},
updateAccountDom: function(account, firstTime) {
var accountNode = account.element;
if (firstTime) {
accountNode.querySelector('.fld-account-name')
.textContent = account.name;
// Highlight the account currently in use
if (this.curAccount && this.curAccount.id === account.id) {
accountNode.classList.add('fld-account-selected');
}
}
},
onFoldersSplice: function(index, howMany, addedItems,
requested, moreExpected) {
var foldersContainer = this.foldersContainer;
var folder;
if (howMany) {
for (var i = index + howMany - 1; i >= index; i--) {
folder = this.foldersSlice.items[i];
foldersContainer.removeChild(folder.element);
}
}
var insertBuddy = (index >= foldersContainer.childElementCount) ?
null : foldersContainer.children[index],
self = this;
addedItems.forEach(function(folder) {
var folderNode = folder.element = fldFolderItemNode.cloneNode(true);
folderNode.folder = folder;
self.updateFolderDom(folder, true);
foldersContainer.insertBefore(folderNode, insertBuddy);
});
},
updateFolderDom: function(folder, firstTime) {
var folderNode = folder.element;
if (firstTime) {
if (!folder.selectable) {
folderNode.classList.add('fld-folder-unselectable');
}
var depthIdx = Math.min(FOLDER_DEPTH_CLASSES.length - 1, folder.depth);
folderNode.classList.add(FOLDER_DEPTH_CLASSES[depthIdx]);
if (depthIdx > 0) {
folderNode.classList.add('fld-folder-depthnonzero');
}
folderNode.querySelector('.fld-folder-name')
.textContent = folder.name;
folderNode.dataset.type = folder.type;
}
if (folder === this.curFolder) {
folderNode.classList.add('fld-folder-selected');
} else {
folderNode.classList.remove('fld-folder-selected');
}
// XXX do the unread count stuff once we have that info
},
onClickFolder: function(folderNode, event) {
var folder = folderNode.folder;
if (!folder.selectable) {
return;
}
var oldFolder = this.curFolder;
this.curFolder = folder;
this.updateFolderDom(oldFolder);
this.updateFolderDom(folder);
this._showFolder(folder);
this._closeCard();
},
onTransitionEnd: function(event) {
// If this is an animation for the content closing, then
// it means the card should be removed now.
if (!this.classList.contains('opened') &&
event.target.classList.contains('fld-content')) {
cards.removeCardAndSuccessors(this, 'none');
// After card is removed, then switch the account, to provide
// smooth animation on closing of drawer.
if (this._waitingAccountId) {
var model = this.model;
model.changeAccountFromId(this._waitingAccountId, function() {
model.selectInbox();
});
this._waitingAccountId = null;
}
}
},
// Closes the card. Relies on onTransitionEnd to do the
// final close, this just sets up the closing transition.
_closeCard: function() {
evt.emit('folderPickerClosing');
this.classList.remove('opened');
},
/**
* Tell the message-list to show this folder; exists for single code path.
*/
_showFolder: function(folder) {
this.model.changeFolder(folder);
},
/**
* When the card is visible, start the animations to show the content
* and fade in the tap shield.
*/
onCardVisible: function() {
this.classList.add('opened');
},
/**
* Our card is going away; perform all cleanup except destroying our DOM.
* This will enable the UI to animate away from our card without weird
* graphical glitches.
*/
die: function() {
this.acctsSlice.die();
this.model.removeListener('account', this.updateAccount);
}
}
];
});

View file

@ -0,0 +1,33 @@
// Default data used for the VScroll component, when data is not loaded yet for
// display in the virtual scroll listing. The u2583 character is a solid bar
// character, so a string of them gives a "redacted" look to a line. Just a
// temporary placeholder until the real data comes in.
define({
'isPlaceholderData': true,
'id': 'INVALID',
'author': {
'name': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583',
'address': '',
'contactId': null
},
'to': [
{
'name': ' ',
'address': ' ',
'contactId': null
}
],
'cc': null,
'bcc': null,
'date': '0',
'hasAttachments': false,
'snippet': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583',
'isRead': true,
'isStarred': false,
'sendStatus': {},
'subject': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583'
});

View file

@ -0,0 +1,190 @@
'use strict';
define(function(require, exports) {
var cards = require('cards'),
ConfirmDialog = require('confirm_dialog'),
deleteConfirmMsgNode = require('tmpl!../msg/delete_confirm.html'),
mozL10n = require('l10n!'),
toaster = require('toaster');
return {
createdCallback: function() {
this.editMode = false;
this.selectedMessages = null;
this.editModeEnabled = false;
},
setEditMode: function(editMode) {
// Do not bother if edit mode is not enabled yet.
if (!this.editModeEnabled) {
return;
}
this._setEditMode(editMode);
},
// This function is called from setEditMode() after ensuring that
// the backend is in a state where we can safely use edit mode.
_setEditMode: function(editMode) {
var i;
this.editMode = editMode;
if (editMode) {
this.classList.add('show-edit');
this.selectedMessages = [];
this.updateDomEditControls();
}
else {
this.classList.remove('show-edit');
this.selectedMessages = null;
}
// Reset checked mode for all message items.
var msgNodes = this.msgVScroll.querySelectorAll('.msg-header-item');
for (i = 0; i < msgNodes.length; i++) {
this.updateDomMessageChecked(msgNodes[i], false);
}
if (this.editModeChanged) {
this.editModeChanged(editMode);
}
},
// Event handler wired up in HTML template
setEditModeStart: function() {
this.setEditMode(true);
},
// Event handler wired up in HTML template
editHeaderClose: function() {
this.setEditMode(false);
},
/**
* Toggles the selection state of the node and therefore the stored
* `selectedMessages` state based on the previous state of the selection.
* The change in the total number of selections is then reflected in DOM
* updates.
* @param {Element} msgNode
*/
toggleSelection: function(msgNode) {
var header = msgNode.message;
var idx = this.selectedMessages.indexOf(header);
if (idx !== -1) {
this.selectedMessages.splice(idx, 1);
} else {
this.selectedMessages.push(header);
}
this.updateDomMessageChecked(msgNode, idx === -1);
this.updateDomEditControls();
},
/**
* Set or unset the select state on a node based on the edit mode.
*/
updateDomSelectState: function(msgNode, message) {
if (this.editMode) {
this.updateDomMessageChecked(msgNode,
this.selectedMessages.indexOf(message) !== -1);
} else {
msgNode.removeAttribute('aria-selected');
}
},
/**
* Set the checked state for the message item in the list. It sets both
* checkbox checked and aria-selected states.
*/
updateDomMessageChecked: function(msgNode, checked) {
var checkbox = msgNode.querySelector('input[type=checkbox]');
checkbox.checked = checked;
msgNode.setAttribute('aria-selected', checked);
},
/**
* Update the edit mode UI bits sensitive to a change in the set of selected
* messages. This means the label that says how many messages are selected,
* whether the buttons are enabled, which of the toggle-pairs are visible.
*/
updateDomEditControls: function() {
this.editHeader.updateDomHeaderCount(this.selectedMessages.length);
this.editToolbar.updateDomEditButtons(this.selectedMessages.length > 0);
// Enabling/disabling rules (not UX-signed-off): Our bias is that people
// want to star messages and mark messages unread (since it they naturally
// end up unread), so unless messages are all in this state, we assume
// that is the desired action.
var numStarred = 0, numRead = 0;
for (var i = 0; i < this.selectedMessages.length; i++) {
var msg = this.selectedMessages[i];
if (msg.isStarred) {
numStarred++;
}
if (msg.isRead) {
numRead++;
}
}
// Unstar if everything is starred, otherwise star
this.setAsStarred = !(numStarred && numStarred ===
this.selectedMessages.length);
this.editToolbar.updateDomStartButton(this.setAsStarred);
// Mark read if everything is unread, otherwise unread
this.setAsRead = (!!this.selectedMessages.length && numRead === 0);
this.editToolbar.updateDomReadButton(numRead > 0);
},
onDeleteMessages: function() {
if (this.selectedMessages.length === 0) {
return this.setEditMode(false);
}
var dialog = deleteConfirmMsgNode.cloneNode(true);
var content = dialog.getElementsByTagName('p')[0];
mozL10n.setAttributes(content, 'message-multiedit-delete-confirm',
{ n: this.selectedMessages.length });
ConfirmDialog.show(dialog,
{ // Confirm
id: 'msg-delete-ok',
handler: function() {
var op = this.model.api.deleteMessages(this.selectedMessages);
toaster.toastOperation(op);
this.setEditMode(false);
}.bind(this)
},
{ // Cancel
id: 'msg-delete-cancel',
handler: null
}
);
},
onStarMessages: function() {
var op = this.model.api.markMessagesStarred(this.selectedMessages,
this.setAsStarred);
this.setEditMode(false);
toaster.toastOperation(op);
},
onMarkMessagesRead: function() {
var op = this.model.api.markMessagesRead(this.selectedMessages,
this.setAsRead);
this.setEditMode(false);
toaster.toastOperation(op);
},
onMoveMessages: function() {
cards.folderSelector(this.model, function(folder) {
var op = this.model.api.moveMessages(this.selectedMessages, folder);
toaster.toastOperation(op);
this.setEditMode(false);
}.bind(this), function(folder) {
return folder.isValidMoveTarget;
});
}
};
});

View file

@ -0,0 +1,12 @@
<section role="region">
<header>
<a href="#"
data-event="click:domEvt"
data-domevt-name="editHeaderClose"
role="button"
data-l10n-id="close-button">
<span class="icon icon-close"></span>
</a>
<h1 data-prop="headerNode"></h1>
</header>
</section>

View file

@ -0,0 +1,16 @@
'use strict';
define(function(require, exports) {
var mozL10n = require('l10n!');
return [
require('../base')(require('template!./edit_header.html')),
require('../mixins/dom_evt'),
{
updateDomHeaderCount: function(count) {
mozL10n.setAttributes(this.headerNode, 'message-multiedit-header',
{ n: count });
}
}
];
});

View file

@ -0,0 +1,31 @@
<!-- Toolbar for multi-edit state -->
<ul class="bb-tablist" role="toolbar">
<li role="presentation">
<button data-prop="deleteBtn"
data-event="click:domEvt"
data-domevt-name="onDeleteMessages"
class="icon msg-delete-btn"
data-l10n-id="message-delete-button"></button>
</li>
<li role="presentation">
<button data-prop="starBtn"
data-event="click:domEvt"
data-domevt-name="onStarMessages"
class="icon msg-star-btn"
data-l10n-id="message-star-button"></button>
</li>
<li role="presentation">
<button data-prop="readBtn"
data-event="click:domEvt"
data-domevt-name="onMarkMessagesRead"
class="icon msg-mark-read-btn"
data-l10n-id="message-mark-read-button"></button>
</li>
<li role="presentation">
<button data-prop="moveBtn"
data-event="click:domEvt"
data-domevt-name="onMoveMessages"
class="icon msg-move-btn"
data-l10n-id="message-move-button"></button>
</li>
</ul>

View file

@ -0,0 +1,47 @@
'use strict';
define(function(require, exports) {
var mozL10n = require('l10n!'),
toolbarEditButtonNames = ['starBtn', 'readBtn', 'deleteBtn', 'moveBtn'];
return [
require('../base')(require('template!./edit_toolbar.html')),
require('../mixins/dom_evt'),
{
updateDomFolderType: function(folderType) {
// You can't move messages in localdrafts or the outbox.
this.moveBtn.classList.toggle('collapsed',
folderType === 'localdrafts' ||
folderType === 'outbox');
// You can't flag or change the read status of messages in the outbox.
this.starBtn.classList.toggle('collapsed',
folderType === 'outbox');
this.readBtn.classList.toggle('collapsed',
folderType === 'outbox');
},
// If true, show the star button, otherwise show unstar button.
updateDomStartButton: function(isStarred) {
mozL10n.setAttributes(this.starBtn,
isStarred ? 'message-star-button' : 'message-unstar-button');
},
// If true, some messages read, so show readBtn as marking unread.
updateDomReadButton: function(hasUnread) {
// Update mark read/unread button to show what action will be taken.
this.readBtn.classList.toggle('unread', hasUnread);
mozL10n.setAttributes(this.readBtn, hasUnread ?
'message-mark-unread-button' : 'message-mark-read-button');
},
// Set how many messages to edit. If greater than zero, enable buttons.
updateDomEditButtons: function(hasMessages) {
// Update disabled state based on if there are selected messages
toolbarEditButtonNames.forEach(function(key) {
this[key].disabled = !hasMessages;
}.bind(this));
}
}
];
});

View file

@ -0,0 +1,158 @@
'use strict';
define(function(require, exports) {
var cards = require('cards'),
ConfirmDialog = require('confirm_dialog'),
largeMsgConfirmMsgNode = require('tmpl!../msg/large_message_confirm.html');
/**
* Show a warning that the given message is large.
* Callback is called with cb(true|false) to continue.
*/
function showLargeMessageWarning(size, cb) {
var dialog = largeMsgConfirmMsgNode.cloneNode(true);
// TODO: If UX designers want the size included in the warning
// message, add it here.
ConfirmDialog.show(dialog,
{ // Confirm
id: 'msg-large-message-ok',
handler: function() { cb(true); }
},
{ // Cancel
id: 'msg-large-message-cancel',
handler: function() { cb(false); }
}
);
}
/**
* A mixin for lst cards that want tapping on a message to go to a message
* reader. Check all the `this` references below to see implicit dependencies
* on instance state.
* A longer range TODO is to remove or make explicit the dependencies.
*/
return {
onClickMessage: function(event) {
var messageNode = event.detail;
// You cannot open a message if this is the outbox and it is syncing.
if (this.curFolder &&
this.curFolder.type === 'outbox' && this.outboxSyncInProgress) {
return;
}
var header = messageNode.message;
// Skip nodes that are default/placeholder ones.
if (header && header.isPlaceholderData) {
return;
}
// If in edit mode, the clicks on message nodes are about changing the
// selection for bulk edit actions.
if (this.editMode) {
this.toggleSelection(messageNode);
return;
}
if (this.curFolder && this.curFolder.type === 'localdrafts') {
var composer = header.editAsDraft(() => {
cards.pushCard('compose', 'animate', {
model: this.model,
composer
});
});
return;
}
// When tapping a message in the outbox, don't open the message;
// instead, move it to localdrafts and edit the message as a
// draft.
if (this.curFolder && this.curFolder.type === 'outbox') {
// If the message is currently being sent, abort.
if (header.sendStatus.state === 'sending') {
return;
}
var draftsFolder =
this.model.foldersSlice.getFirstFolderWithType('localdrafts');
console.log('outbox: Moving message to localdrafts.');
this.model.api.moveMessages([header], draftsFolder, (moveMap) => {
header.id = moveMap[header.id];
console.log('outbox: Editing message in localdrafts.');
var composer = header.editAsDraft(() => {
cards.pushCard('compose', 'animate', {
model: this.model,
composer
});
});
});
return;
}
var model = this.model,
headerCursor = this.headerCursor;
function pushMessageCard() {
cards.pushCard(
'message_reader', 'animate',
{
model,
headerCursor,
// The header here may be undefined here, since the click
// could be on a cached HTML node before the back end has
// started up. It is OK if header is not available as the
// message_reader knows how to wait for the back end to
// start up to get the header value later.
header,
// Use the property on the HTML, since the click could be
// from a cached HTML node and the real data object may not
// be available yet.
messageSuid: messageNode.dataset.id
});
}
if (header) {
this.headerCursor.setCurrentMessage(header);
} else if (messageNode.dataset.id) {
// a case where header was not set yet, like clicking on a
// html cached node, or virtual scroll item that is no
// longer backed by a header.
this.headerCursor.setCurrentMessageBySuid(messageNode.dataset.id);
} else {
// Not an interesting click, bail
return;
}
// If the message is really big, warn them before they open it.
// Ideally we'd only warn if you're on a cell connection
// (metered), but as of now `navigator.connection.metered` isn't
// implemented.
// This number is somewhat arbitrary, based on a guess that most
// plain-text/HTML messages will be smaller than this. If this
// value is too small, users get warned unnecessarily. Too large
// and they download a lot of data without knowing. Since we
// currently assume that all network connections are metered,
// they'll always see this if they get a large message...
var LARGE_MESSAGE_SIZE = 1 * 1024 * 1024;
// watch out, header might be undefined here (that's okay, see above)
if (header && header.bytesToDownloadForBodyDisplay > LARGE_MESSAGE_SIZE) {
showLargeMessageWarning(
header.bytesToDownloadForBodyDisplay, function(result) {
if (result) {
pushMessageCard();
} else {
// abort
}
});
} else {
pushMessageCard();
}
}
};
});

View file

@ -0,0 +1,22 @@
<div data-prop="vScrollContainer" class="msg-vscroll-container"></div>
<!-- maintain vertical space for the syncing/sync more div's
regardless of their displayed status so we don't scroll them
out of the way -->
<div class="msg-messages-sync-container">
<p data-prop="syncingNode" class="msg-messages-syncing collapsed"
role="progressbar" data-l10n-id="messages-syncing-progressbar">
<span data-l10n-id="messages-syncing"></span>
</p>
<p data-prop="syncMoreNode"
data-event="click:onGetMoreMessages"
class="msg-messages-sync-more collapsed"
role="button">
<span data-l10n-id="messages-load-more"></span>
</p>
</div>
<div data-prop="messageEmptyContainer"
class="msg-list-empty-container collapsed">
<p data-prop="messageEmptyText"
class="msg-list-empty-message-text"
data-l10n-id="messages-folder-empty"></p>
</div>

View file

@ -0,0 +1,511 @@
'use strict';
define(function(require, exports) {
var cards = require('cards'),
containerListen = require('container_listen'),
mozL10n = require('l10n!'),
msgHeaderItemNode = require('tmpl!../msg/header_item.html'),
toaster = require('toaster'),
VScroll = require('vscroll');
var sliceEvents = ['splice', 'change', 'status', 'complete'];
/**
* Minimum number of items there must be in the message slice
* for us to attempt to limit the selection of snippets to fetch.
*/
var MINIMUM_ITEMS_FOR_SCROLL_CALC = 10;
/**
* Maximum amount of time between issuing snippet requests.
*/
var MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST = 6000;
/**
* Fetch up to 4kb while scrolling
*/
var MAXIMUM_BYTES_PER_MESSAGE_DURING_SCROLL = 4 * 1024;
/**
* Component that shows a message-based vscroll. Assumes the following are set
* on the this component before vscroll is active:
* - headerCursor
*/
return [
require('../base')(require('template!./msg_vscroll.html')),
{
createdCallback: function() {
this.setAttribute('role', 'listbox');
this.setAttribute('aria-multiselectable', 'true');
mozL10n.setAttributes(this.messageEmptyText, this.dataset.emptyL10nId);
containerListen(this.vScrollContainer, 'click',
this.onClickMessage.bind(this));
},
/**
* Call this from the createdCallback of the module that wants to display
* a message-based vscroll.
*/
init: function(scrollContainer, bindData, defaultVScrollData) {
this.scrollContainer = scrollContainer;
// Set up the list data source for VScroll
var listFunc = (function(index) {
return this.headerCursor.messagesSlice.items[index];
}.bind(this));
listFunc.size = (function() {
// This method could get called during VScroll updates triggered
// by messages_splice. However at that point, the headerCount may
// not be correct, like when fetching more messages from the
// server. So approximate by using the size of slice.items.
var slice = this.headerCursor.messagesSlice;
// coerce headerCount to 0 if it was undefined to avoid a NaN
return Math.max(slice.headerCount || 0, slice.items.length);
}.bind(this));
this.listFunc = listFunc;
// We need to wait for the slice to complete before we can issue any
// sensible growth requests.
this.waitingOnChunk = true;
this.desiredHighAbsoluteIndex = 0;
this._needVScrollData = false;
this.vScroll = new VScroll(
this.vScrollContainer,
this.scrollContainer,
msgHeaderItemNode,
defaultVScrollData
);
// Called by VScroll wants to bind some data to a node it wants to
// display in the DOM.
this.vScroll.bindData = bindData;
// Called by VScroll when it detects it will need more data in the near
// future. VScroll does not know if it already asked for this
// information, so this function needs to be sure it actually needs to
// ask for more from the back end.
this.vScroll.prepareData = (function(highAbsoluteIndex) {
var items = this.headerCursor.messagesSlice &&
this.headerCursor.messagesSlice.items,
headerCount = this.headerCursor.messagesSlice.headerCount;
if (!items || !headerCount) {
return;
}
// Make sure total amount stays within possible range.
if (highAbsoluteIndex > headerCount - 1) {
highAbsoluteIndex = headerCount - 1;
}
// We're already prepared if the slice is already that big.
if (highAbsoluteIndex < items.length) {
return;
}
this.loadNextChunk(highAbsoluteIndex);
}.bind(this));
},
setHeaderCursor: function(headerCursor) {
if (this.headerCursor) {
throw new Error('headerCursor already set');
}
this.headerCursor = headerCursor;
sliceEvents.forEach(function(type) {
var name = 'messages_' + type;
// Bind to local, but replace property name here so that removing the
// listeners are easier in die().
this[name] = this[name].bind(this);
headerCursor.on(name, this[name]);
}.bind(this));
this.onCurrentMessage = this.onCurrentMessage.bind(this);
headerCursor.on('currentMessage', this.onCurrentMessage);
// Find the containing element that is a card, for use when asking if that
// card is visible and extra work should be done.
var parent = this;
while((parent = parent.parentNode)) {
if (parent.classList.contains('card')) {
break;
}
}
this.cardParent = parent;
this._onVScrollStopped = this._onVScrollStopped.bind(this);
this.vScroll.on('scrollStopped', this._onVScrollStopped);
},
/**
* Used to remove the cached HTML from a cache restore of HTML. Do not use
* this for clearing the display of messages when the list of messages
* change, instead use methods on the vScroll object.
*/
removeMessagesHtml: function() {
this.vScrollContainer.innerHTML = '';
},
onClickMessage: function(node, event) {
this.emitDomEvent('messageClick', node);
},
/**
* Waits for scrolling to stop before fetching snippets.
*/
_onVScrollStopped: function() {
// Give any pending requests in the slice priority.
if (!this.headerCursor.messagesSlice ||
this.headerCursor.messagesSlice.pendingRequestCount) {
return;
}
// Do not bother fetching snippets if this card is not in view.
// The card could still have a scroll event triggered though
// by the next/previous work done in message_reader.
if (cards.isVisible(this.cardParent) && !this._hasSnippetRequest()) {
this._requestSnippets();
}
},
onGetMoreMessages: function() {
if (!this.headerCursor.messagesSlice) {
return;
}
// For accessibility purposes, focus on the first newly loaded item in the
// messages list. This will ensure that screen reader's cursor position
// will get updated to the right place.
this.vScroll.once('recalculated', function(calledFromTop, refIndex) {
// refIndex is the index of the first new message item.
this.vScrollContainer.querySelector(
'[data-index="' + refIndex + '"]').focus();
}.bind(this));
this.headerCursor.messagesSlice.requestGrowth(1, true);
},
isEmpty: function() {
return this.headerCursor.messagesSlice.items.length === 0;
},
/**
* Hide buttons that are not appropriate if we have no messages and display
* the appropriate l10n string in the message list proper.
*/
showEmptyLayout: function() {
this.messageEmptyContainer.classList.remove('collapsed');
this.emit('emptyLayoutShown');
},
/**
* Show buttons we hid in `showEmptyLayout` and hide the "empty folder"
* message.
*/
hideEmptyLayout: function() {
this.messageEmptyContainer.classList.add('collapsed');
this.emit('emptyLayoutHidden');
},
// The funny name because it is auto-bound as a listener for
// messagesSlice events in headerCursor using a naming convention.
messages_status: function(newStatus) {
var syncInProgress = true;
if (newStatus === 'synchronizing' ||
newStatus === 'syncblocked') {
this.syncingNode.classList.remove('collapsed');
this.syncMoreNode.classList.add('collapsed');
this.hideEmptyLayout();
} else if (newStatus === 'syncfailed' ||
newStatus === 'synced') {
syncInProgress = false;
if (newStatus === 'syncfailed') {
// If there was a problem talking to the server, notify the user and
// provide a means to attempt to talk to the server again. We have
// made onRefresh pretty clever, so it can do all the legwork on
// accomplishing this goal.
toaster.toast({
text: mozL10n.get('toaster-retryable-syncfailed')
});
}
this.syncingNode.classList.add('collapsed');
}
this.emit('syncInProgress', syncInProgress);
},
/**
* @param {number=} newEmailCount Optional number of new messages.
* The funny name because it is auto-bound as a listener for
* messagesSlice events in headerCursor using a naming convention.
*/
messages_complete: function(newEmailCount) {
var headerCursor = this.headerCursor;
console.log('message_list complete:',
headerCursor.messagesSlice.items.length, 'items of',
headerCursor.messagesSlice.headerCount,
'alleged known headers. canGrow:',
headerCursor.messagesSlice.userCanGrowDownwards);
// Show "load more", but only if the slice can grow and if there is a
// non-zero headerCount. If zero headerCount, it likely means the folder
// has never been synchronized, and this display was an offline display,
// so it is hard to know if messages can be synchronized. In this case,
// canGrow is not enough of an indicator, because as far as the back end is
// concerned, it could grow, it just has no way to check for sure yet. So
// hide the "load more", the user can use the refresh icon once online to
// load messages.
if (headerCursor.messagesSlice.userCanGrowDownwards &&
headerCursor.messagesSlice.headerCount) {
this.syncMoreNode.classList.remove('collapsed');
} else {
this.syncMoreNode.classList.add('collapsed');
}
// Show empty layout, unless this is a slice with fake data that
// will get changed soon.
if (headerCursor.messagesSlice.items.length === 0) {
this.showEmptyLayout();
}
this.waitingOnChunk = false;
// Load next chunk if one is pending
if (this.desiredHighAbsoluteIndex) {
this.loadNextChunk(this.desiredHighAbsoluteIndex);
this.desiredHighAbsoluteIndex = 0;
}
// It's possible for a synchronization to result in a change to
// headerCount without resulting in a splice. This is very likely
// to happen with a search filter when it was lying about another
// messages existing, but it's also possible to happen in
// synchronizations.
//
// XXX Our total correctness currently depends on headerCount only
// changing as a result of a synchronization triggered by this slice.
// This does not hold up when confronted with periodic background sync; we
// need to finish cleanup up the headerCount change notification stuff.
//
// (However, this is acceptable glitchage right now. We just need to make
// sure it doesn't happen for searches since it's so blatant.)
//
// So, anyways, use updateDataBind() to cause VScroll to double-check that
// our list size didn't change from what it thought it was. (It renders
// coordinate-space predictively based on our headerCount, but we
// currently only provide strong correctness guarantees for actually
// reported `items`, so we must do this.) If our list size is the same,
// this call is effectively a no-op.
this.vScroll.updateDataBind(0, [], 0);
this.emit('messagesComplete', newEmailCount);
},
// The funny name because it is auto-bound as a listener for
// messagesSlice events in headerCursor using a naming convention.
messages_splice: function(index, howMany, addedItems,
requested, moreExpected) {
var headerCursor = this.headerCursor;
// If no work to do, just skip it.
if (index === 0 && howMany === 0 && !addedItems.length) {
return;
}
this.emit('messagesSpliceStart', index, howMany, addedItems,
requested, moreExpected);
if (this._needVScrollData) {
this.vScroll.setData(this.listFunc);
this._needVScrollData = false;
}
this.vScroll.updateDataBind(index, addedItems, howMany);
// Remove the no message text while new messages added:
if (addedItems.length > 0) {
this.hideEmptyLayout();
}
// If the end result is no more messages, then show empty layout.
// This is needed mostly because local drafts do not trigger
// a messages_complete callback when removing the last draft
// from the compose triggered in that view. The scrollStopped
// is used to avoid a flash where the old message is briefly visible
// before cleared, and having the empty layout overlay it.
// Using the slice's headerCount because it is updated before splice
// listeners are notified, so should be accurate.
if (!headerCursor.messagesSlice.headerCount) {
this.vScroll.once('scrollStopped', function() {
// Confirm there are still no messages. Since this callback happens
// async, some items could have appeared since first issuing the
// request to show empty.
if (!headerCursor.messagesSlice.headerCount) {
this.showEmptyLayout();
}
}.bind(this));
}
this.emit('messagesSpliceEnd', index, howMany, addedItems,
requested, moreExpected);
},
// The funny name because it is auto-bound as a listener for
// messagesSlice events in headerCursor using a naming convention.
messages_change: function(message, index) {
this.emit('messagesChange', message, index);
},
/**
* Request data through desiredHighAbsoluteIndex if we don't have it
* already and we think it exists. If we already have an outstanding
* request we will save off this most recent request to process once
* the current request completes. Any previously queued request will
* be forgotten regardless of how it compares to the newly queued
* request.
*
* @param {Number} desiredHighAbsoluteIndex
*/
loadNextChunk: function(desiredHighAbsoluteIndex) {
// The recalculate logic will trigger a call to prepareData, so
// it's okay for us to bail. It's advisable for us to bail
// because any calls to prepareData will be based on outdated
// index information.
if (this.vScroll.waitingForRecalculate) {
return;
}
if (this.waitingOnChunk) {
this.desiredHighAbsoluteIndex = desiredHighAbsoluteIndex;
return;
}
// Do not bother asking for more than exists
var headerCursor = this.headerCursor;
if (desiredHighAbsoluteIndex >= headerCursor.messagesSlice.headerCount) {
desiredHighAbsoluteIndex = headerCursor.messagesSlice.headerCount - 1;
}
// Do not bother asking for more than what is already
// fetched
var items = headerCursor.messagesSlice.items;
var curHighAbsoluteIndex = items.length - 1;
var amount = desiredHighAbsoluteIndex - curHighAbsoluteIndex;
if (amount > 0) {
// IMPORTANT NOTE!
// 1 is unfortunately a special value right now for historical reasons
// that the other side interprets as a request to grow downward with the
// default growth size. XXX change backend and its tests...
console.log('message_list loadNextChunk growing', amount,
(amount === 1 ? '(will get boosted to 15!) to' : 'to'),
(desiredHighAbsoluteIndex + 1), 'items out of',
headerCursor.messagesSlice.headerCount, 'alleged known');
headerCursor.messagesSlice.requestGrowth(
amount,
// the user is not requesting us to go synchronize new messages
false);
this.waitingOnChunk = true;
}
},
/**
* Scroll to make sure that the current message is in our visible window.
*
* @param {header_cursor.CurrentMessage} currentMessage representation of
* the email we're currently reading.
* @param {Number} index the index of the message in the messagesSlice
*/
onCurrentMessage: function(currentMessage, index) {
if (!currentMessage) {
return;
}
var visibleIndices = this.vScroll.getVisibleIndexRange();
if (visibleIndices &&
(index < visibleIndices[0] || index > visibleIndices[1])) {
this.vScroll.jumpToIndex(index);
}
},
_hasSnippetRequest: function() {
var max = MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST;
var now = Date.now();
// if we before the maximum time to wait between requests...
var beforeTimeout =
(this._lastSnippetRequest + max) > now;
// there is an important case where the backend may be slow OR have some
// fatal error which would prevent us from ever requesting an new set of
// snippets because we wait until the last batch finishes. To prevent that
// from ever happening we maintain the request start time and if more then
// MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST passes we issue a new request.
if (this._snippetRequestPending && beforeTimeout) {
return true;
}
return false;
},
_pendingSnippetRequest: function() {
this._snippetRequestPending = true;
this._lastSnippetRequest = Date.now();
},
_clearSnippetRequest: function() {
this._snippetRequestPending = false;
},
_requestSnippets: function() {
var headerCursor = this.headerCursor;
var items = headerCursor.messagesSlice.items;
var len = items.length;
if (!len) {
return;
}
var clearSnippets = this._clearSnippetRequest.bind(this);
var options = {
// this is per message
maximumBytesToFetch: MAXIMUM_BYTES_PER_MESSAGE_DURING_SCROLL
};
if (len < MINIMUM_ITEMS_FOR_SCROLL_CALC) {
this._pendingSnippetRequest();
headerCursor.messagesSlice.maybeRequestBodies(0,
MINIMUM_ITEMS_FOR_SCROLL_CALC - 1, options, clearSnippets);
return;
}
var visibleIndices = this.vScroll.getVisibleIndexRange();
if (visibleIndices) {
this._pendingSnippetRequest();
headerCursor.messagesSlice.maybeRequestBodies(
visibleIndices[0],
visibleIndices[1],
options,
clearSnippets
);
}
},
die: function() {
sliceEvents.forEach(function(type) {
var name = 'messages_' + type;
this.headerCursor.removeListener(name, this[name]);
}.bind(this));
this.headerCursor.removeListener('currentMessage', this.onCurrentMessage);
this.vScroll.destroy();
}
}
];
});

View file

@ -0,0 +1,8 @@
'use strict';
define(function() {
return {
update: function(peep) {
peep.element.textContent = peep.name || peep.address;
}
};
});

View file

@ -0,0 +1,89 @@
<!-- Non-search header -->
<section data-prop="normalHeader"
class="msg-list-header msg-nonsearch-only"
data-statuscolor="default"
role="region">
<header>
<!-- Unlike a generic back button that navigates to a different screen,
folder list header button triggers the folders and settings overlay. Thus
the screen reader user requires more context as to what activating the
button would do. -->
<a href="#" class="msg-folder-list-btn" data-event="click:onShowFolders"
aria-expanded="false" aria-controls="cards-folder-picker"
role="button" data-l10n-id="message-list-menu">
<span class="icon icon-menu"></span>
</a>
<menu data-prop="headerMenuNode" type="toolbar" class="anim-opacity">
<a href="#" class="msg-compose-btn" data-event="click:onCompose"
data-l10n-id="message-list-compose">
<span class="icon icon-compose"></span>
</a>
</menu>
<h1 data-prop="folderLabel"
class="msg-list-header-folder-label header-label">
<span data-prop="folderNameNode"
dir="auto"
class="msg-list-header-folder-name"></span>
<span data-prop="folderUnread"
class="msg-list-header-folder-unread collapsed"></span>
</h1>
</header>
</section>
<!-- Multi-edit state header -->
<lst-edit-header data-prop="editHeader"
data-event="editHeaderClose"></lst-edit-header>
<!-- Scroll region -->
<div data-prop="scrollContainer" class="msg-list-scrollouter">
<!-- exists so we can force a minimum height -->
<div class="msg-list-scrollinner">
<!-- The search textbox hides under the lip of the messages.
As soon as any typing happens in it, we push the search
controls card. -->
<form role="search" data-prop="searchBar"
class="msg-search-tease-bar msg-nonsearch-only">
<p>
<input data-event="focus:onSearchButton"
data-prop="searchTextTease"
class="msg-search-text-tease" type="text"
dir="auto"
data-l10n-id="message-search-input" />
</p>
</form>
<lst-msg-vscroll data-prop="msgVScroll"
data-event="messageClick:onClickMessage"
data-empty-l10n-id="messages-folder-empty">
</lst-msg-vscroll>
</div>
</div>
<!-- New email notification bar -->
<div class="message-list-topbar"></div>
<!-- Toolbar for non-multi-edit state -->
<ul data-prop="normalToolbar" class="bb-tablist msg-list-action-toolbar"
role="toolbar">
<li role="presentation" class="msg-nonsearch-only">
<button data-prop="refreshBtn" data-event="click:onRefresh"
class="icon msg-refresh-btn" data-state="synchronized"
data-l10n-id="messages-refresh-button">
</button>
</li>
<li role="status" class="msg-nonsearch-only msg-last-sync">
<span data-prop="lastSyncedLabel"
class="msg-last-synced-label"
data-l10n-id="folder-last-synced-label"></span>
<span data-prop="lastSyncedAtNode"
class="msg-last-synced-value"></span>
</li>
<li role="presentation">
<button data-prop="editBtn" data-event="click:setEditModeStart"
class="icon msg-edit-btn" data-l10n-id="edit-button"></button>
</li>
</ul>
<lst-edit-toolbar data-prop="editToolbar"
data-event="onDeleteMessages,onStarMessages,
onMarkMessagesRead,onMoveMessages">
</lst-edit-toolbar>

View file

@ -0,0 +1,974 @@
/*global FontSizeUtils, requestAnimationFrame */
'use strict';
define(function(require, exports, module) {
var cards = require('cards'),
date = require('date'),
defaultVScrollData = require('./lst/default_vscroll_data'),
evt = require('evt'),
toaster = require('toaster'),
HeaderCursor = require('header_cursor'),
htmlCache = require('html_cache'),
mozL10n = require('l10n!'),
MessageListTopBar = require('message_list_topbar'),
messageDisplay = require('message_display'),
updatePeepDom = require('./lst/peep_dom').update,
VScroll = require('vscroll');
/**
* List messages for listing the contents of folders. Multi-editing is just a
* state of the card.
*
* == Cache behavior ==
*
* This is a card that can be instantiated using the cached HTML stored by the
* html_cache. As such, it is constructed to allow clicks on message list items
* before the back end has loaded up, and to know how to refresh the cached
* state by looking at the use the usingCachedNode property. It also prevents
* clicks from button actions that need back end data to complete if the click
* would result in a card that cannot also handle delayed back end startup.
* It tracks if the back end has started up by checking curFolder, which is
* set to a data object sent from the back end.
*
* == Less-than-infinite scrolling ==
*
* A dream UI would be to let the user smoothly scroll through all of the
* messages in a folder, syncing them from the server as-needed. The limits
* on this are 1) bandwidth cost, and 2) storage limitations.
*
* Our sync costs are A) initial sync of a time range, and B) update sync of a
* time range. #A is sufficiently expensive that it makes sense to prompt the
* user when we are going to sync further into a time range. #B is cheap
* enough and having already synced the time range suggests sufficient user
* interest.
*
* So the way our UI works is that we do an infinite-scroll-type thing for
* messages that we already know about. If we are on metered bandwidth, then
* we require the user to click a button in the display list to sync more
* messages. If we are on unmetered bandwidth, we will eventually forego that.
* (For testing purposes right now, we want to pretend everything is metered.)
* We might still want to display a button at some storage threshold level,
* like if the folder is already using a lot of space.
*
* See `onScroll` for more details.
*
* XXX this class wants to be cleaned up, badly. A lot of this may want to
* happen via pushing more of the hiding/showing logic out onto CSS, taking
* care to use efficient selectors.
*
*/
return [
require('./base_card')(require('template!./message_list.html')),
require('./lst/edit_controller'),
require('./lst/msg_click'),
{
createdCallback: function() {
// Sync display
this._needsSizeLastSync = true;
this.updateLastSynced();
this.curFolder = null;
this.isIncomingFolder = true;
// Binding "this" to some functions as they are used for event listeners.
this._hideSearchBoxByScrolling =
this._hideSearchBoxByScrolling.bind(this);
this._folderChanged = this._folderChanged.bind(this);
this.onNewMail = this.onNewMail.bind(this);
this.onFoldersSliceChange = this.onFoldersSliceChange.bind(this);
this.usingCachedNode = this.dataset.cached === 'cached';
this.msgVScroll.on('messagesSpliceStart', function(index,
howMany,
addedItems,
requested,
moreExpected) {
this._clearCachedMessages();
}.bind(this));
this.msgVScroll.on('messagesSpliceEnd', function(index,
howMany,
addedItems,
requested,
moreExpected) {
// Only cache if it is an add or remove of items
if (addedItems.length || howMany) {
this._considerCacheDom(index);
}
}.bind(this));
this.msgVScroll.on('messagesChange', function(message, index) {
this.onMessagesChange(message, index);
}.bind(this));
this._emittedContentEvents = false;
this.msgVScroll.on('messagesComplete', function(newEmailCount) {
this.onNewMail(newEmailCount);
// Inform that content is ready. There could actually be a small delay
// with vScroll.updateDataBind from rendering the final display, but it
// is small enough that it is not worth trying to break apart the design
// to accommodate this metrics signal.
if (!this._emittedContentEvents) {
evt.emit('metrics:contentDone');
this._emittedContentEvents = true;
}
}.bind(this));
// Outbox has some special concerns, override status method to account for
// it. Do this **before** initing the vscroll, so that the override is
// used.
var oldMessagesStatus = this.msgVScroll.messages_status;
this.msgVScroll.messages_status = function(newStatus) {
// The outbox's refresh button is used for sending messages, so we
// ignore any syncing events generated by the slice. The outbox
// doesn't need to show many of these indicators (like the "Load
// More Messages..." node, etc.) and it has its own special
// "refreshing" display, as documented elsewhere in this file.
if (!this.curFolder || this.curFolder.type === 'outbox') {
return;
}
return oldMessagesStatus.call(this.msgVScroll, newStatus);
}.bind(this);
this.msgVScroll.on('emptyLayoutShown', function() {
this._clearCachedMessages();
// The outbox can't refresh anything if there are no messages.
if (this.curFolder.type === 'outbox') {
this.refreshBtn.disabled = true;
}
this.editBtn.disabled = true;
this._hideSearchBoxByScrolling();
}.bind(this));
this.msgVScroll.on('emptyLayoutHidden', function() {
this.editBtn.disabled = false;
this.refreshBtn.disabled = false;
}.bind(this));
this.msgVScroll.on('syncInProgress', function(syncInProgress) {
if (syncInProgress) {
this.setRefreshState(true);
} else {
this.setRefreshState(false);
this._manuallyTriggeredSync = false;
}
}.bind(this));
var vScrollBindData = (function bindNonSearch(model, node) {
model.element = node;
node.message = model;
this.updateMessageDom(model);
}).bind(this);
this.msgVScroll.init(this.scrollContainer,
vScrollBindData,
defaultVScrollData);
// Event listeners for VScroll events.
this.msgVScroll.vScroll.on('inited', this._hideSearchBoxByScrolling);
this.msgVScroll.vScroll.on('dataChanged', this._hideSearchBoxByScrolling);
this.msgVScroll.vScroll.on('recalculated', function(calledFromTop) {
if (calledFromTop) {
this._hideSearchBoxByScrolling();
}
}.bind(this));
this._topBar = new MessageListTopBar(
this.querySelector('.message-list-topbar')
);
this._topBar.bindToElements(this.scrollContainer,
this.msgVScroll.vScroll);
this.onFolderPickerClosing = this.onFolderPickerClosing.bind(this);
evt.on('folderPickerClosing', this.onFolderPickerClosing);
},
onArgs: function(args) {
var model = this.model = args.model;
var headerCursor = this.headerCursor = args.headerCursor ||
new HeaderCursor(model);
this.msgVScroll.setHeaderCursor(headerCursor);
model.latest('folder', this._folderChanged);
model.on('newInboxMessages', this.onNewMail);
model.on('backgroundSendStatus', this.onBackgroundSendStatus.bind(this));
model.on('foldersSliceOnChange', this.onFoldersSliceChange);
// If this card is created after header_cursor is set up
// with a messagesSlice, then need to bootstrap this card
// to catch up, since the normal events will not fire.
// Common scenarios for this case are: going back to the
// message list after reading a message from a notification,
// or from a compose triggered from an activity. However,
// only do this if there is a current folder. A case
// where there is not a folder: after deleting an account,
// and the UI is bootstrapping back to existing account.
// Also, search pushes a new message_list card, but search
// needs a special slice, created only when the search
// actually starts. So do not bootstrap in that case.
if (this.curFolder) {
var items = headerCursor.messagesSlice &&
headerCursor.messagesSlice.items;
if (items && items.length) {
this.msgVScroll.messages_splice(0, 0, items);
this.msgVScroll.messages_complete(0);
}
}
},
/**
* Inform Cards to not emit startup content events, this card will trigger
* them once data from back end has been received and the DOM is up to date
* with that data.
* @type {Boolean}
*/
skipEmitContentEvents: true,
postInsert: function() {
this._hideSearchBoxByScrolling();
// Now that _hideSearchBoxByScrolling has activated the display
// of the search box, get the height of the search box and tell
// vScroll about it, but only do this once the DOM is displayed
// so the ClientRect gives an actual height.
this.msgVScroll.vScroll.visibleOffset =
this.searchBar.getBoundingClientRect().height;
// Also tell the MessageListTopBar
this._topBar.visibleOffset = this.msgVScroll.vScroll.visibleOffset;
// For search we want to make sure that we capture the screen size prior
// to focusing the input since the FxOS keyboard will resize our window to
// be smaller which messes up our logic a bit. We trigger metric
// gathering in non-search cases too for consistency.
this.msgVScroll.vScroll.captureScreenMetrics();
},
onSearchButton: function() {
// Do not bother if there is no current folder.
if (!this.curFolder) {
return;
}
cards.pushCard(
'message_list_search', 'animate',
{
model: this.model,
folder: this.curFolder
});
},
_hideSearchBoxByScrolling: function() {
// scroll the search bit out of the way
var searchBar = this.searchBar,
scrollContainer = this.scrollContainer;
// Search bar could have been collapsed with a cache load,
// make sure it is visible, but if so, adjust the scroll
// position in case the user has scrolled before this code
// runs.
if (searchBar.classList.contains('collapsed')) {
searchBar.classList.remove('collapsed');
scrollContainer.scrollTop += searchBar.offsetHeight;
}
// Adjust scroll position now that there is something new in
// the scroll region, but only if at the top. Otherwise, the
// user's purpose scroll positioning may be disrupted.
//
// Note that when we call vScroll.clearDisplay() we
// inherently scroll back up to the top, so this check is still
// okay even when switching folders. (We do not want to start
// index 50 in our new folder because we were at index 50 in our
// old folder.)
if (scrollContainer.scrollTop === 0) {
scrollContainer.scrollTop = searchBar.offsetHeight;
}
},
onShowFolders: function() {
cards.pushCard('folder_picker', 'immediate', {
model: this.model,
onPushed: function() {
this.headerMenuNode.classList.add('transparent');
}.bind(this)
});
},
onCompose: function() {
cards.pushCard('compose', 'animate', {
model: this.model
});
},
/**
* If the last synchronised label is more than half the length
* of its display area, set a "long" style on it that allows
* different styling. But only do this once per card instance,
* the label should not change otherwise.
* TODO though, once locale changing in app is supported, this
* should be revisited.
*/
sizeLastSync: function() {
if (this._needsSizeLastSync && this.lastSyncedLabel.scrollWidth) {
var label = this.lastSyncedLabel;
var overHalf = label.scrollWidth > label.parentNode.clientWidth / 2;
label.parentNode.classList[(overHalf ? 'add' : 'remove')]('long');
this._needsSizeLastSync = false;
}
},
updateLastSynced: function(value) {
var method = value ? 'remove' : 'add';
this.lastSyncedLabel.classList[method]('collapsed');
date.setPrettyNodeDate(this.lastSyncedAtNode, value);
this.sizeLastSync();
},
updateUnread: function(num) {
var content = '';
if (num > 0) {
content = num > 999 ? mozL10n.get('messages-folder-unread-max') : num;
}
this.folderUnread.textContent = content;
this.folderUnread.classList.toggle('collapsed', !content);
this.callHeaderFontSize();
},
onFoldersSliceChange: function(folder) {
if (folder === this.curFolder) {
this.updateUnread(folder.unread);
this.updateLastSynced(folder.lastSyncedAt);
}
},
/**
* A workaround for font_size_utils not recognizing child node content
* changing, and if it did, it would be noisy/extra work if done
* generically. Using a rAF call to not slow down the rest of card updates,
* it is something that can happen lazily on another turn.
*/
callHeaderFontSize: function(node) {
requestAnimationFrame(function() {
FontSizeUtils._reformatHeaderText(this.folderLabel);
}.bind(this));
},
/**
* Show a folder, returning true if we actually changed folders or false if
* we did nothing because we were already in the folder.
*/
showFolder: function(folder, forceNewSlice) {
if (folder === this.curFolder && !forceNewSlice) {
return false;
}
// If using a cache, do not clear the HTML as it will
// be cleared once real data has been fetched.
if (!this.usingCachedNode) {
// This inherently scrolls us back up to the top of the list.
this.msgVScroll.vScroll.clearDisplay();
}
this.msgVScroll._needVScrollData = true;
this.curFolder = folder;
// Now that a folder is available, enable edit mode toggling.
this.editModeEnabled = true;
switch (folder.type) {
case 'drafts':
case 'localdrafts':
case 'outbox':
case 'sent':
this.isIncomingFolder = false;
break;
default:
this.isIncomingFolder = true;
break;
}
this.folderNameNode.textContent = folder.name;
this.updateUnread(folder.unread);
this.msgVScroll.setAttribute('aria-label', folder.name);
this.msgVScroll.hideEmptyLayout();
// You can't refresh messages in the localdrafts folder.
this.refreshBtn.classList.toggle('collapsed',
folder.type === 'localdrafts');
this.editToolbar.updateDomFolderType(folder.type);
this.updateLastSynced(folder.lastSyncedAt);
if (forceNewSlice) {
// We are creating a new slice, so any pending snippet requests are
// moot.
this.msgVScroll._snippetRequestPending = false;
this.headerCursor.freshMessagesSlice();
}
this.onFolderShown();
return true;
},
/**
* This is an override of setEditMode in lst/edit_controller because the
* outbox needs special treatment.
*/
setEditMode: function(editMode) {
// Do not bother if edit mode is not enabled yet.
if (!this.editModeEnabled) {
return;
}
if (this.curFolder.type === 'outbox') {
// You cannot edit the outbox messages if the outbox is syncing.
if (editMode && this.outboxSyncInProgress) {
return;
}
// Outbox Sync and Edit Mode are mutually exclusive. Disable
// outbox syncing before allowing us to enter edit mode, and
// vice versa. The callback shouldn't take long, but we wait to
// trigger edit mode until outbox sync has been fully disabled,
// to prevent ugly theoretical race conditions.
var model = this.model;
model.api.setOutboxSyncEnabled(model.account, !editMode, function() {
this._setEditMode(editMode);
}.bind(this));
} else {
this._setEditMode(editMode);
}
},
/**
* Set the refresh button state based on the new message status.
*/
setRefreshState: function(syncing) {
if (syncing) {
this.refreshBtn.dataset.state = 'synchronizing';
this.refreshBtn.setAttribute('role', 'progressbar');
mozL10n.setAttributes(this.refreshBtn, 'messages-refresh-progress');
} else {
this.refreshBtn.dataset.state = 'synchronized';
this.refreshBtn.removeAttribute('role');
mozL10n.setAttributes(this.refreshBtn, 'messages-refresh-button');
}
},
onNewMail: function(newEmailCount) {
var inboxFolder = this.model.foldersSlice.getFirstFolderWithType('inbox');
if (inboxFolder.id === this.curFolder.id &&
newEmailCount && newEmailCount > 0) {
if (!cards.isVisible(this)) {
this._whenVisible = this.onNewMail.bind(this, newEmailCount);
return;
}
// If the user manually synced, then want to jump to show the new
// messages. Otherwise, show the top bar.
if (this._manuallyTriggeredSync) {
this.msgVScroll.vScroll.jumpToIndex(0);
} else {
// Update the existing status bar.
this._topBar.showNewEmailCount(newEmailCount);
}
}
},
// When an email is being sent from the app (and not from an outbox
// refresh), we'll receive notification here. Play a sound and
// raise a toast, if appropriate.
onBackgroundSendStatus: function(data) {
if (this.curFolder.type === 'outbox') {
if (data.state === 'sending') {
// If the message is now sending, make sure we're showing the
// outbox as "currently being synchronized".
this.toggleOutboxSyncingDisplay(true);
} else if (data.state === 'syncDone') {
this.toggleOutboxSyncingDisplay(false);
}
}
if (data.emitNotifications) {
toaster.toast({
text: data.localizedDescription
});
}
},
/**
* How many items in the message list to keep for the _cacheDom call.
* @type {Number}
*/
_cacheListLimit: 7,
/**
* Tracks if a DOM cache save is scheduled for later.
* @type {Number}
*/
_cacheDomTimeoutId: 0,
/**
* Confirms card state is in a visual state suitable for caching.
*/
_isCacheableCardState: function() {
return this.cacheableFolderId === this.curFolder.id && !this.editMode;
},
/**
* Caches the DOM for this card, but trims it down a bit first.
*/
_cacheDom: function() {
this._cacheDomTimeoutId = 0;
if (!this._isCacheableCardState()) {
return;
}
// Safely clone the node so we can mutate the tree to cut out the parts
// we do not want/need.
var cacheNode =
htmlCache.cloneAsInertNodeAvoidingCustomElementHorrors(this);
// Make sure toolbar is visible, could be hidden by drawer
cacheNode.querySelector('menu[type="toolbar"]')
.classList.remove('transparent');
// Hide search field as it will not operate and gets scrolled out
// of view after real load.
var removableCacheNode = cacheNode.querySelector('.msg-search-tease-bar');
if (removableCacheNode) {
removableCacheNode.classList.add('collapsed');
}
// Hide "new mail" topbar too
removableCacheNode = cacheNode.querySelector('.message-list-topbar');
if (removableCacheNode) {
this._topBar.resetNodeForCache(removableCacheNode);
}
// Hide the last sync number
var tempNode = cacheNode.querySelector('.msg-last-synced-label');
if (tempNode) {
tempNode.classList.add('collapsed');
}
tempNode = cacheNode.querySelector('.msg-last-synced-value');
if (tempNode) {
tempNode.innerHTML = '';
}
// Trim vScroll containers that are not in play
VScroll.trimMessagesForCache(
cacheNode.querySelector('.msg-vscroll-container'),
this._cacheListLimit
);
htmlCache.saveFromNode(module.id, cacheNode);
},
/**
* Considers a DOM cache, but only if it meets the criteria for what
* should be saved in the cache, and if a save is not already scheduled.
* @param {Number} index the index of the message that triggered
* this call.
*/
_considerCacheDom: function(index) {
// Only bother if not already waiting to update cache and
if (!this._cacheDomTimeoutId &&
// card visible state is appropriate
this._isCacheableCardState() &&
// if the scroll area is at the top (otherwise the
// virtual scroll may be showing non-top messages)
this.msgVScroll.vScroll.firstRenderedIndex === 0 &&
// if actually got a numeric index and
(index || index === 0) &&
// if it affects the data we cache
index < this._cacheListLimit) {
this._cacheDomTimeoutId = setTimeout(this._cacheDom.bind(this), 600);
}
},
/**
* Clears out the messages HTML in messageContainer from using the cached
* nodes that were picked up when the HTML cache of this list was used
* (which is indicated by usingCachedNode being true). The cached HTML
* needs to be purged when the real data is finally available and will
* replace the cached state. A more sophisticated approach would be to
* compare the cached HTML to what would be inserted in its place, and
* if no changes, skip this step, but that comparison operation could get
* tricky, and it is cleaner just to wipe it and start fresh. Once the
* cached HTML has been cleared, then usingCachedNode is set to false
* to indicate that the main piece of content in the card, the message
* list, is no longer from a cached node.
*/
_clearCachedMessages: function() {
if (this.usingCachedNode) {
this.msgVScroll.removeMessagesHtml();
this.usingCachedNode = false;
}
},
onMessagesChange: function(message, index) {
this.updateMessageDom(message);
// Since the DOM change, cache may need to change.
this._considerCacheDom(index);
},
/**
* Update the state of the given DOM node. Note that DOM nodes are reused
* so you must ensure that this method cleans up any dirty state resulting
* from any possible prior operation of this method.
*/
updateMessageDom: function(message) {
var msgNode = message.element;
if (!msgNode) {
return;
}
// If the placeholder data, indicate that in case VScroll
// wants to go back and fix later.
var classAction = message.isPlaceholderData ? 'add' : 'remove';
var defaultDataClass = this.msgVScroll.vScroll.itemDefaultDataClass;
msgNode.classList[classAction](defaultDataClass);
// ID is stored as a data- attribute so that it can survive
// serialization to HTML for storing in the HTML cache, and
// be usable before the actual data from the backend has
// loaded, as clicks to the message list are allowed before
// the back end is available. For this reason, click
// handlers should use dataset.id when wanting the ID.
msgNode.dataset.id = message.id;
// some things only need to be done once
var dateNode = msgNode.querySelector('.msg-header-date');
var subjectNode = msgNode.querySelector('.msg-header-subject');
var snippetNode = msgNode.querySelector('.msg-header-snippet');
var listPerson;
if (this.isIncomingFolder) {
listPerson = message.author;
// XXX This is not to UX spec, but this is a stop-gap and that would
// require adding strings which we cannot justify as a slipstream fix.
} else if (message.to && message.to.length) {
listPerson = message.to[0];
} else if (message.cc && message.cc.length) {
listPerson = message.cc[0];
} else if (message.bcc && message.bcc.length) {
listPerson = message.bcc[0];
} else {
listPerson = message.author;
}
// author
listPerson.element =
msgNode.querySelector('.msg-header-author');
listPerson.onchange = updatePeepDom;
listPerson.onchange(listPerson);
// date
var dateTime = dateNode.dataset.time = message.date.valueOf();
date.relativeDateElement(dateNode, dateTime);
// subject
messageDisplay.subject(msgNode.querySelector('.msg-header-subject'),
message);
// attachments (can't change within a message but can change between
// messages, and since we reuse DOM nodes...)
var attachmentsNode = msgNode.querySelector('.msg-header-attachments');
attachmentsNode.classList.toggle('msg-header-attachments-yes',
message.hasAttachments);
// snippet needs to be shorter if icon is shown
snippetNode.classList.toggle('icon-short', message.hasAttachments);
// snippet
snippetNode.textContent = message.snippet;
// update styles throughout the node for read vs unread
msgNode.classList.toggle('unread', !message.isRead);
// star
var starNode = msgNode.querySelector('.msg-header-star');
starNode.classList.toggle('msg-header-star-starred', message.isStarred);
// subject needs to give space for star if it is visible
subjectNode.classList.toggle('icon-short', message.isStarred);
// sync status
var syncNode =
msgNode.querySelector('.msg-header-syncing-section');
// sendState is only intended for outbox messages, so not all
// messages will have sendStatus defined.
var sendState = message.sendStatus && message.sendStatus.state;
syncNode.classList.toggle('msg-header-syncing-section-syncing',
sendState === 'sending');
syncNode.classList.toggle('msg-header-syncing-section-error',
sendState === 'error');
// Set the accessible label for the syncNode.
if (sendState) {
mozL10n.setAttributes(syncNode, 'message-header-state-' + sendState);
} else {
syncNode.removeAttribute('data-l10n-id');
}
// edit mode select state, defined in lst/edit_controller
this.updateDomSelectState(msgNode, message);
},
/**
* Called when the folder picker is animating to close. Need to
* listen for it so this card can animate fading in the header menu.
*/
onFolderPickerClosing: function() {
this.headerMenuNode.classList.remove('transparent');
},
/**
* Listener called when a folder is shown. The listener emits an
* 'inboxShown' for the current account, if the inbox is really being shown
* and the app is visible. Useful if periodic sync is involved, and
* notifications need to be closed if the inbox is visible to the user.
*/
onFolderShown: function() {
var model = this.model,
account = model.account,
foldersSlice = model.foldersSlice;
// The extra checks here are to allow for lazy startup when we might have
// a card instance but not a full model available. Once the model is
// available though, this method will get called again, so the event
// emitting is still correctly done in the lazy startup case.
if (!document.hidden && account && foldersSlice && this.curFolder) {
var inboxFolder = foldersSlice.getFirstFolderWithType('inbox');
if (inboxFolder === this.curFolder) {
evt.emit('inboxShown', account.id);
}
// If user tapped in search box on message_list before the JS for the
// card is attached, then treat that as the signal to go to search. Only
// do this when first starting up though.
if (document.activeElement === this.searchTextTease) {
this.onSearchButton();
}
}
},
/**
* An API method for the cards infrastructure, that Cards will call when the
* page visibility changes and this card is the currently displayed card.
*/
onCurrentCardDocumentVisibilityChange: function() {
this.onFolderShown();
},
/**
* Called by Cards when the instance of this card type is the
* visible card.
*/
onCardVisible: function() {
if (this._whenVisible) {
var fn = this._whenVisible;
this._whenVisible = null;
fn();
}
// In case the vScroll was initialized when the card was not visible, like
// in an activity/notification flow when this card is created in the
// background behind the compose/reader card, let it know it is visible
// now in case it needs to finish initializing and initial display.
this.msgVScroll.vScroll.nowVisible();
// On first construction, or if done in background,
// this card would not be visible to do the last sync
// sizing so be sure to check it now.
this.sizeLastSync();
},
/**
* The outbox has a special role in the message_list, compared to
* other folders. We don't expect to synchronize the outbox with the
* server, but we do allow the user to use the refresh button to
* trigger all of the outbox messages to send.
*
* While they're sending, we need to display several spinny refresh
* icons: One next to each message while it's queued for sending,
* and also the main refresh button.
*
* However, the outbox send operation doesn't happen all in one go;
* the backend only fires one 'sendOutboxMessages' at a time,
* iterating through the pending messages. Fortunately, it notifies
* the frontend (via `onBackgroundSendStatus`) whenever the state of
* any message changes, and it provides a flag to let us know
* whether or not the outbox sync is fully complete.
*
* So the workflow for outbox's refresh UI display is as follows:
*
* 1. The user taps the "refresh" button. In response:
*
* 1a. Immediately make all visible refresh icons start spinning.
*
* 1b. Immediately kick off a 'sendOutboxMessages' job.
*
* 2. We will start to see send status notifications, in this
* class's onBackgroundSendStatus notification. We listen to
* these events as they come in, and wait until we see a
* notification with state === 'syncDone'. We'll keep the main
* refresh icon spinning throughout this process.
*
* 3. As messages send or error out, we will receive slice
* notifications for each message (handled here in `messages_change`).
* Since each message holds its own status as `header.sendStatus`,
* we don't need to do anything special; the normal rendering logic
* will reset each message's status icon to the appropriate state.
*
* But don't take my word for it; see `jobs/outbox.js` and
* `jobmixins.js` in GELAM for backend-centric descriptions of how
* the outbox sending process works.
*/
toggleOutboxSyncingDisplay: function(syncing) {
// Use an internal guard so that we only trigger changes to the UI
// when necessary, rather than every time, which could break animations.
if (syncing === this._outboxSyncing) {
return;
}
this._outboxSyncing = syncing;
var i;
var items = this.msgVScroll.getElementsByClassName(
'msg-header-syncing-section');
if (syncing) {
// For maximum perceived responsiveness, show the spinning icons
// next to each message immediately, rather than waiting for the
// backend to actually start sending each message. When the
// backend reports back with message results, it'll update the
// icon to reflect the proper result.
for (i = 0; i < items.length; i++) {
items[i].classList.add('msg-header-syncing-section-syncing');
items[i].classList.remove('msg-header-syncing-section-error');
}
this.editBtn.disabled = true;
} else {
// After sync, the edit button should remain disabled only if
// the list is empty.
this.editBtn.disabled = this.msgVScroll.isEmpty();
// Similarly, we must stop the refresh icons for each message
// from rotating further. For instance, if we are offline, we
// won't actually attempt to send any of those messages, so
// they'll still have a spinny icon until we forcibly remove it.
for (i = 0; i < items.length; i++) {
items[i].classList.remove('msg-header-syncing-section-syncing');
}
}
this.setRefreshState(syncing);
},
onRefresh: function() {
var headerCursor = this.headerCursor;
if (!headerCursor.messagesSlice) {
return;
}
// If this is the outbox, refresh has a different meaning.
if (this.curFolder.type === 'outbox') {
// Rather than refreshing the folder, we'll send the pending
// outbox messages, and spin the refresh icon while doing so.
this.toggleOutboxSyncingDisplay(true);
}
// If this is a normal folder...
else {
switch (headerCursor.messagesSlice.status) {
// If we're still synchronizing, then the user is not well served by
// queueing a refresh yet, let's just squash this.
case 'new':
case 'synchronizing':
break;
// If we fully synchronized, then yes, let us refresh.
case 'synced':
this._manuallyTriggeredSync = true;
headerCursor.messagesSlice.refresh();
break;
// If we failed to talk to the server, then let's only do a refresh if
// we know about any messages. Otherwise let's just create a new slice
// by forcing reentry into the folder.
case 'syncfailed':
if (headerCursor.messagesSlice.items.length) {
headerCursor.messagesSlice.refresh();
} else {
this.showFolder(this.curFolder, /* force new slice */ true);
}
break;
}
}
// Even if we're not actually viewing the outbox right now, we
// should still attempt to sync any pending messages. It's fairly
// harmless to kick off this job here, but it could also make
// sense to do this at the backend level. There are a number of
// cases where we might also want to sendOutboxMessages() if
// we follow up with a more comprehensive sync setting -- e.g. on
// network change, on app startup, etc., so it's worth revisiting
// this and how coupled we want incoming vs outgoing sync to be.
this.model.api.sendOutboxMessages(this.model.account);
},
_folderChanged: function(folder) {
// It is possible that the notification of latest folder is fired
// but in the meantime the foldersSlice could be cleared due to
// a change in the current account, before this listener is called.
// So skip this work if no foldersSlice, this method will be called
// again soon.
if (!this.model.foldersSlice) {
return;
}
// Folder could have changed because account changed. Make sure
// the cacheableFolderId is still set correctly.
var model = this.model;
var inboxFolder = model.foldersSlice.getFirstFolderWithType('inbox');
this.cacheableFolderId =
model.account === model.acctsSlice.defaultAccount ?
inboxFolder.id : null;
if (this.showFolder(folder)) {
this._hideSearchBoxByScrolling();
}
},
die: function() {
this.msgVScroll.die();
evt.removeListener('folderPickerClosing', this.onFolderPickerClosing);
if (this.headerCursor) {
this.headerCursor.die();
}
var model = this.model;
model.removeListener('folder', this._folderChanged);
model.removeListener('newInboxMessages', this.onNewMail);
model.removeListener('foldersSliceOnChange', this.onFoldersSliceChange);
}
}
];
});

View file

@ -0,0 +1,81 @@
<!-- Search header -->
<section data-prop="searchHeader"
class="msg-search-header"
role="region">
<form role="search" data-event="submit:onSearchSubmit">
<button data-event="click:onCancelSearch"
class="msg-search-cancel" type="submit"
data-l10n-id="message-search-cancel"></button>
<p>
<input data-prop="searchInput" data-event="input:onSearchTextChange"
type="text" required="required" class="msg-search-text"
autocorrect="off"
inputmode="verbatim"
x-inputmode="verbatim"
dir="auto"
data-l10n-id="message-search-input" />
<button type="reset"
data-l10n-id="form-clear-input"
data-event="click:onClearSearch"></button>
</p>
</form>
<!-- Search filter switcher -->
<header class="msg-search-controls-bar">
<ul role="tablist" class="bb-tablist filter" data-type="filter">
<li role="presentation" class="msg-search-from msg-search-filter"
data-filter="author">
<a data-l10n-id="message-search-from" role="tab"
aria-selected="false"></a></li>
<li role="presentation" class="msg-search-to msg-search-filter"
data-filter="recipients">
<a data-l10n-id="message-search-to" role="tab"
aria-selected="false"></a></li>
<li role="presentation" class="msg-search-subject msg-search-filter"
data-filter="subject">
<a data-l10n-id="message-search-subject" role="tab"
aria-selected="false"></a>
</li>
<li role="presentation" class="msg-search-body msg-search-filter"
data-filter="body">
<a data-l10n-id="message-search-body" role="tab"
aria-selected="false"></a></li>
<li role="presentation" class="msg-search-body msg-search-filter"
data-filter="all">
<a data-l10n-id="message-search-all" role="tab"
aria-selected="true"></a></li>
</ul>
</header>
</section>
<!-- Multi-edit state header -->
<lst-edit-header data-prop="editHeader"
data-event="editHeaderClose"></lst-edit-header>
<!-- Scroll region -->
<div data-prop="scrollContainer" class="msg-list-scrollouter">
<!-- exists so we can force a minimum height -->
<div class="msg-list-scrollinner">
<lst-msg-vscroll data-prop="msgVScroll"
data-event="messageClick:onClickMessage"
data-empty-l10n-id="messages-search-empty">
</lst-msg-vscroll>
</div>
</div>
<!-- New email notification bar -->
<div class="message-list-topbar"></div>
<!-- Toolbar for non-multi-edit state -->
<ul data-prop="normalToolbar" class="bb-tablist msg-list-action-toolbar"
role="toolbar">
<li role="presentation">
<button data-prop="editBtn" data-event="click:setEditModeStart"
class="icon msg-edit-btn" data-l10n-id="edit-button"></button>
</li>
</ul>
<lst-edit-toolbar data-prop="editToolbar"
data-event="onDeleteMessages,onStarMessages,
onMarkMessagesRead,onMoveMessages">
</lst-edit-toolbar>

View file

@ -0,0 +1,323 @@
'use strict';
define(function(require, exports) {
var accessibilityHelper = require('shared/js/accessibility_helper'),
cards = require('cards'),
containerListen = require('container_listen'),
date = require('date'),
HeaderCursor = require('header_cursor'),
messageDisplay = require('message_display'),
MessageListTopBar = require('message_list_topbar'),
updatePeepDom = require('./lst/peep_dom').update;
// We will display this loading data for any messages we are
// pretending exist so that the UI has a reason to poke the search
// slice to do more work.
var defaultSearchVScrollData = {
header: require('./lst/default_vscroll_data'),
matches: []
};
var MATCHED_TEXT_CLASS = 'highlight';
function appendMatchItemTo(matchItem, node) {
var text = matchItem.text;
var idx = 0;
for (var iRun = 0; iRun <= matchItem.matchRuns.length; iRun++) {
var run;
if (iRun === matchItem.matchRuns.length) {
run = { start: text.length, length: 0 };
} else {
run = matchItem.matchRuns[iRun];
}
// generate the un-highlighted span
if (run.start > idx) {
var tnode = document.createTextNode(text.substring(idx, run.start));
node.appendChild(tnode);
}
if (!run.length) {
continue;
}
var hspan = document.createElement('span');
hspan.classList.add(MATCHED_TEXT_CLASS);
hspan.textContent = text.substr(run.start, run.length);
node.appendChild(hspan);
idx = run.start + run.length;
}
}
return [
require('./base_card')(require('template!./message_list_search.html')),
require('./lst/edit_controller'),
require('./lst/msg_click'),
{
createdCallback: function() {
containerListen(this.querySelector('.filter'), 'click',
this.onSearchFilterClick.bind(this));
this.searchFilterTabs = this.querySelectorAll('.filter [role="tab"]');
this.isFirstTimeVisible = true;
// Binding "this" to some functions as they are used for event listeners.
this._folderChanged = this._folderChanged.bind(this);
this.msgVScroll.on('emptyLayoutShown', function() {
this.editBtn.disabled = true;
}.bind(this));
this.msgVScroll.on('emptyLayoutHidden', function() {
this.editBtn.disabled = false;
}.bind(this));
this.msgVScroll.on('messagesChange', function(message, index) {
this.updateMatchedMessageDom(message);
}.bind(this));
this.msgVScroll.on('messagesComplete', function(newEmailCount) {
// Search does not trigger normal conditions for a folder changed,
// so if vScroll is missing its data, set it up now.
if (!this.msgVScroll.vScroll.list) {
this.msgVScroll.vScroll.setData(this.msgVScroll.listFunc);
}
}.bind(this));
var vScrollBindData = (function bindSearch(model, node) {
model.element = node;
node.message = model.header;
this.updateMatchedMessageDom(model);
}).bind(this);
this.msgVScroll.init(this.scrollContainer,
vScrollBindData,
defaultSearchVScrollData);
this._topBar = new MessageListTopBar(
this.querySelector('.message-list-topbar')
);
this._topBar.bindToElements(this.scrollContainer,
this.msgVScroll.vScroll);
},
onArgs: function(args) {
var model = this.model = args.model;
var headerCursor = this.headerCursor = args.headerCursor ||
new HeaderCursor(model);
this.msgVScroll.setHeaderCursor(headerCursor);
model.latest('folder', this._folderChanged);
},
onCardVisible: function() {
// First time this card is visible, want the search field focused if this
// is a search. Do not want to do it on every cardVisible, as the user
// could be scrolled/have their own place in the search results, and are
// likely going back and forth between this card and message_reader.
if (this.isFirstTimeVisible) {
this.searchInput.focus();
}
this.msgVScroll.vScroll.nowVisible();
this.isFirstTimeVisible = false;
},
showSearch: function(phrase, filter) {
console.log('sf: showSearch. phrase:', phrase, phrase.length);
this.curFolder = this.model.folder;
this.editModeEnabled = true;
this.msgVScroll.vScroll.clearDisplay();
this.curPhrase = phrase;
this.curFilter = filter;
// We are creating a new slice, so any pending snippet requests are moot.
this.msgVScroll._snippetRequestPending = false;
// Don't bother the new slice with requests until we hears it completion
// event.
this.msgVScroll.waitingOnChunk = true;
this.headerCursor.startSearch(phrase, {
author: filter === 'all' || filter === 'author',
recipients: filter === 'all' || filter === 'recipients',
subject: filter === 'all' || filter === 'subject',
body: filter === 'all' || filter === 'body'
});
return true;
},
onSearchFilterClick: function(filterNode, event) {
accessibilityHelper.setAriaSelected(filterNode.firstElementChild,
this.searchFilterTabs);
this.showSearch(this.searchInput.value, filterNode.dataset.filter);
},
onSearchTextChange: function(event) {
console.log('sf: typed, now:', this.searchInput.value);
this.showSearch(this.searchInput.value, this.curFilter);
},
onSearchSubmit: function(event) {
// Not a real form to submit, so stop actual submission.
event.preventDefault();
// Blur the focus away from the text input. This has the effect of hiding
// the keyboard. This is useful for the two cases where this function is
// currently triggered: Enter on the keyboard or Cancel on form submit.
// Note that the Cancel button has a type="submit", which is technically
// an incorrect use of that type. However the /shared styles depend on it
// being marked as such for style reasons.
this.searchInput.blur();
},
onCancelSearch: function(event) {
// Only care about real clicks on actual button, not fake ones triggered
// by a form submit from the Enter button on the keyboard.
// Note: the cancel button should not really be a type="submit", but it is
// that way because the /shared styles for this search form wants to see
// a submit. Longer term this should be changed in the /shared components.
// This event test is used because in form submit cases, the
// explicitOriginalTarget (the text input) is not the same as the target
// (the button).
if (event.explicitOriginalTarget !== event.target) {
return;
}
try {
this.headerCursor.endSearch();
}
catch (ex) {
console.error('problem killing slice:', ex, '\n', ex.stack);
}
cards.removeCardAndSuccessors(this, 'animate');
},
onClearSearch: function() {
this.showSearch('', this.curFilter);
},
/**
* Called from edit_controller mixin when the edit mode has changed. Used to
* allow classes mixing in edit_controller to update UI state based on edit
* state.
*/
editModeChanged: function(enabled) {
if (enabled) {
// Set status color to match the edit header.
cards.setStatusColor(this.editHeader);
} else {
// Return the status color to match this card.
cards.setStatusColor(this);
}
},
updateMatchedMessageDom: function(matchedHeader) {
var msgNode = matchedHeader.element,
matches = matchedHeader.matches,
message = matchedHeader.header;
if (!msgNode) {
return;
}
// If the placeholder data, indicate that in case VScroll
// wants to go back and fix later.
var classAction = message.isPlaceholderData ? 'add' : 'remove';
var defaultDataClass = this.msgVScroll.vScroll.itemDefaultDataClass;
msgNode.classList[classAction](defaultDataClass);
// Even though updateMatchedMessageDom is only used in searches,
// which likely will not be cached, the dataset.is is set to
// maintain parity withe updateMessageDom and so click handlers
// can always just use the dataset property.
msgNode.dataset.id = matchedHeader.id;
// some things only need to be done once
var dateNode = msgNode.querySelector('.msg-header-date');
var subjectNode = msgNode.querySelector('.msg-header-subject');
// author
var authorNode = msgNode.querySelector('.msg-header-author');
if (matches.author) {
authorNode.textContent = '';
appendMatchItemTo(matches.author, authorNode);
}
else {
// we can only update the name if it wasn't matched on.
message.author.element = authorNode;
message.author.onchange = updatePeepDom;
message.author.onchange(message.author);
}
// date
var dateTime = dateNode.dataset.time = message.date.valueOf();
date.relativeDateElement(dateNode, dateTime);
// subject
if (matches.subject) {
subjectNode.textContent = '';
appendMatchItemTo(matches.subject[0], subjectNode);
} else {
messageDisplay.subject(subjectNode, message);
}
// snippet
var snippetNode = msgNode.querySelector('.msg-header-snippet');
if (matches.body) {
snippetNode.textContent = '';
appendMatchItemTo(matches.body[0], snippetNode);
} else {
snippetNode.textContent = message.snippet;
}
// attachments (can't change within a message but can change between
// messages, and since we reuse DOM nodes...)
var attachmentsNode =
msgNode.querySelector('.msg-header-attachments');
attachmentsNode.classList.toggle('msg-header-attachments-yes',
message.hasAttachments);
// snippet needs to be shorter if icon is shown
snippetNode.classList.toggle('icon-short', message.hasAttachments);
// Set unread state.
msgNode.classList.toggle('unread', !message.isRead);
// star
var starNode = msgNode.querySelector('.msg-header-star');
starNode.classList.toggle('msg-header-star-starred', message.isStarred);
// subject needs to give space for star if it is visible
subjectNode.classList.toggle('icon-short', message.isStarred);
// edit mode select state, defined in lst/edit_controller
this.updateDomSelectState(msgNode, message);
},
_folderChanged: function(folder) {
// It is possible that the notification of latest folder is fired
// but in the meantime the foldersSlice could be cleared due to
// a change in the current account, before this listener is called.
// So skip this work if no foldersSlice, this method will be called
// again soon.
if (!this.model.foldersSlice) {
return;
}
this.curFolder = folder;
this.showSearch('', 'all');
},
die: function() {
this.msgVScroll.die();
this.model.removeListener('folder', this._folderChanged);
}
}
];
});

View file

@ -0,0 +1,92 @@
<section class="msg-reader-header" role="region" data-statuscolor="default">
<header>
<a data-event="click:onBack" href="#" class="msg-back-btn"
role="button" data-l10n-id="back-button">
<span class="icon icon-back"></span>
</a>
<menu type="toolbar">
<button data-prop="previousBtn" class="msg-up-btn"
data-l10n-id="message-reader-next-button">
<span data-prop="previousIcon" class="icon icon-up"></span>
</button>
<button data-prop="nextBtn" class="msg-down-btn"
data-l10n-id="message-reader-previous-button">
<span data-prop="nextIcon" class="icon icon-down"></span>
</button>
</menu>
<h1 dir="auto" class="msg-reader-header-label"></h1>
</header>
</section>
<div data-prop="scrollContainer"
class="scrollregion-below-header scrollregion-horizontal-too">
<div data-prop="envelopeBar" class="msg-envelope-bar">
<div class="msg-envelope-line msg-envelope-from-line"
role="listbox" data-l10n-id="from-group">
<span class="msg-envelope-key"
data-l10n-id="envelope-from" aria-hidden="true"></span>
</div>
<!-- the details starts out collapsed, but can be toggled -->
<div class="msg-envelope-details">
<div class="msg-envelope-line msg-envelope-to-line"
role="listbox" data-l10n-id="to-group">
<span class="msg-envelope-key"
data-l10n-id="envelope-to" aria-hidden="true"></span>
</div>
<div class="msg-envelope-line msg-envelope-cc-line"
role="listbox" data-l10n-id="cc-group">
<span class="msg-envelope-key"
data-l10n-id="envelope-cc" aria-hidden="true"></span>
</div>
<div class="msg-envelope-line msg-envelope-bcc-line"
role="listbox" data-l10n-id="bcc-group">
<span class="msg-envelope-key"
data-l10n-id="envelope-bcc" aria-hidden="true"></span>
</div>
</div>
<div class="msg-envelope-subject-container">
<h3 aria-level="2" class="msg-envelope-subject"
data-l10n-id="subject" dir="auto"></h3>
<span role="heading" aria-level="3" class="msg-envelope-date"
data-l10n-id="message-reader-date-received"></span>
</div>
</div>
<ul data-prop="attachmentsContainer"
class="msg-attachments-container collapsed"
data-l10n-id="attachments-container">
</ul>
<!-- Tells us about remote/not downloaded images, asks to show -->
<div data-prop="loadBar" class="msg-reader-load-infobar collapsed"
role="button">
<p data-prop="loadBarText" class="msg-reader-load-infobar-text"></p>
<button class="msg-reader-load-infobar-button"
aria-hidden="true"></button>
</div>
<div data-prop="rootBodyNode"
class="msg-body-container"
data-l10n-id="message-body-container">
<progress data-l10n-id="message-body-container-progress"></progress>
</div>
</div>
<ul class="bb-tablist msg-reader-action-toolbar" role="toolbar">
<li role="presentation">
<button data-prop="deleteBtn" class="icon msg-delete-btn"
data-l10n-id="message-delete-button"></button>
</li>
<li role="presentation">
<button data-prop="starBtn" class="icon msg-star-btn"
data-l10n-id="message-star-button"></button>
</li>
<li role="presentation">
<button data-prop="readBtn" class="icon msg-mark-read-btn"
data-l10n-id="message-mark-read-button"></button>
</li>
<li role="presentation">
<button data-prop="moveBtn" class="icon msg-move-btn"
data-l10n-id="message-move-button"></button>
</li>
<li role="presentation">
<button data-prop="replyBtn" class="icon msg-reply-btn"
data-l10n-id="message-reply-forward-button"></button>
</li>
</ul>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
'use strict';
define(function () {
var slice = Array.prototype.slice;
return {
templateInsertedCallback: function () {
slice.call(this.querySelectorAll('[data-event]'))
.forEach(function (node) {
var parent = node;
// Make sure the node is not nested in another component.
while ((parent = parent.parentNode)) {
if (parent.nodeName.indexOf('-') !== -1) {
if (parent !== this) {
return;
}
break;
}
}
if (!parent) {
return;
}
// Value is of type 'name:value,name:value',
// with the :value part optional.
node.dataset.event.split(',').forEach(function (pair) {
var evtName, method,
parts = pair.split(':');
if (!parts[1]) {
parts[1] = parts[0];
}
evtName = parts[0].trim();
method = parts[1].trim();
if (typeof this[method] !== 'function') {
throw new Error('"' + method +
'" is not a function, cannot bind with data-event');
}
node.addEventListener(evtName, function(evt) {
// Treat these events as private to the
// custom element.
evt.stopPropagation();
return this[method](evt);
}.bind(this), false);
}.bind(this));
}.bind(this));
}
};
});

View file

@ -0,0 +1,28 @@
'use strict';
define(function () {
return {
templateInsertedCallback: function () {
var node, parent,
nodes = this.querySelectorAll('[data-prop]'),
length = nodes.length;
for (var i = 0; i < length; i++) {
// Make sure the node is not nested in another component.
node = nodes[i];
parent = node;
while ((parent = parent.parentNode)) {
if (parent.nodeName.indexOf('-') !== -1) {
if (parent !== this) {
node = null;
}
break;
}
}
if (node && parent) {
this[node.dataset.prop] = nodes[i];
}
}
}
};
});

View file

@ -0,0 +1,39 @@
'use strict';
define(function () {
/**
* domEvt can be used with data-event="" to translate internal template
* events into custom events emitted from the custom element that contains
* the template element that triggered the event. Use like so in the template:
*
* <a href="#" data-event="click:domEvt" data-domevt-name="headerClose">
*
* When that a tag receives a click event (from itself or its children) then
* domEvt will convert that to a custom event "headerClose", dispatched on the
* custom element.
*
* data-domevt-detail="something" allows passing a "detail" string in the
* custom event that will have the value of "something".
*/
return {
domEvt: function (event) {
var target = event.currentTarget,
eventName = target.dataset.domevtName,
eventData = {},
eventDetail = target.dataset.domevtDetail;
event.preventDefault();
event.stopPropagation();
if (!eventName) {
throw new Error('No data-domevt-name on element <' +
target.nodeName + ' class="' + target.className + '">');
}
if (eventDetail) {
eventData.detail = eventDetail;
}
this.dispatchEvent(new CustomEvent(eventName, eventData));
}
};
});

View file

@ -0,0 +1,9 @@
<form role="dialog" class="msg-attach-confirm" data-type="confirm">
<section>
<h1></h1>
<p></p>
</section>
<menu>
<button id="msg-attach-ok" class="full" data-l10n-id="dialog-button-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,9 @@
<form role="dialog" class="msg-attachment-did-not-open" data-type="confirm">
<section>
<h1 data-l10n-id="message-attachment-did-not-open-label"></h1>
<p><span data-l10n-id="message-attachment-did-not-open-body"></span></p>
</section>
<menu>
<button id="msg-attachment-did-not-open-ok" data-l10n-id="dialog-button-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,9 @@
<form role="dialog" class="msg-attachment-disabled-confirm" data-type="confirm">
<section>
<p data-l10n-id="message-send-attachment-disabled-confirm"></p>
</section>
<menu>
<button id="msg-attachment-disabled-cancel" data-l10n-id="message-multiedit-cancel"></button>
<button id="msg-attachment-disabled-ok" data-l10n-id="dialog-button-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,17 @@
<li class="msg-attachment-item">
<span class="msg-attachment-icon" role="presentation"></span>
<span class="msg-attachment-fileinfo">
<span dir="auto" class="msg-attachment-filename"></span>
<span class="msg-attachment-filesize"></span>
<span data-l10n-id="message-attachment-too-large"
class="msg-attachment-too-large"></span>
</span>
<button class="msg-attachment-download" data-l10n-id="download-button">
<span class="icon icon-download"></span></button>
<span class="msg-attachment-downloading">
<progress class="small" data-l10n-id="downloading-progress"></progress>
</span>
<button class="msg-attachment-view" data-l10n-id="view-button">
<span data-l10n-id="message-attachment-view" class="icon icon-view"></span>
</button>
</li>

View file

@ -0,0 +1,10 @@
<form role="dialog" class="msg-browse-confirm" data-type="confirm">
<section>
<h1 data-l10n-id="confirm-dialog-title"></h1>
<p></p>
</section>
<menu>
<button id="msg-browse-cancel" data-l10n-id="message-multiedit-cancel"></button>
<button id="msg-browse-ok" class="recommend" data-l10n-id="dialog-button-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,19 @@
<form class="msg-contact-menu" role="dialog" data-type="action">
   <header></header>
   <menu>
<button class="msg-contact-menu-new collapsed" data-l10n-id="message-contact-menu-new">
</button>
<button class="msg-contact-menu-reply collapsed" data-l10n-id="message-contact-menu-reply">
</button>
<button class="msg-contact-menu-view collapsed" data-l10n-id="message-contact-menu-view">
</button>
<button class="msg-contact-menu-create-contact collapsed" data-l10n-id="message-contact-menu-create">
</button>
<button class="msg-contact-menu-add-to-existing-contact collapsed" data-l10n-id="message-contact-menu-add-existing">
</button>
<span class="last-button-container">
<button class="msg-contact-menu-cancel" data-l10n-id="message-multiedit-cancel">
</button>
</span>
   </menu>
 </form>

View file

@ -0,0 +1,10 @@
<form role="dialog" class="msg-delete-confirm" data-type="confirm">
<section>
<h1 data-l10n-id="confirm-dialog-title"></h1>
<p></p>
</section>
<menu>
<button id="msg-delete-cancel" data-l10n-id="message-multiedit-cancel"></button>
<button id="msg-delete-ok" class="danger" data-l10n-id="message-edit-menu-delete"></button>
</menu>
</form>

View file

@ -0,0 +1,19 @@
<a class="msg-header-item" role="option" tabindex="0">
<label class="pack-checkbox negative" aria-hidden="true">
<input type="checkbox"><span></span>
</label>
<div class="msg-header-details-section">
<span dir="auto" class="msg-header-author"></span>
<span dir="auto" class="msg-header-subject"></span>
<span dir="auto" class="msg-header-date"></span>
<span dir="auto" class="msg-header-snippet"></span>
</div>
<div class="msg-header-syncing-section"></div>
<div class="msg-header-unread-section"
data-l10n-id="message-header-unread"></div>
<div class="msg-header-icons-section">
<span class="msg-header-star" data-l10n-id="message-header-starred"></span>
<span class="msg-header-attachments"
data-l10n-id="message-header-attachments"></span>
</div><div class="msg-header-avatar-section" aria-hidden="true">
</div></a>

View file

@ -0,0 +1,10 @@
<form role="dialog" class="msg-large-message-confirm" data-type="confirm">
<section>
<h1 data-l10n-id="confirm-dialog-title"></h1>
<p><span data-l10n-id="message-large-message-confirm"></span></p>
</section>
<menu>
<button id="msg-large-message-cancel" data-l10n-id="message-large-message-cancel"></button>
<button id="msg-large-message-ok" data-l10n-id="message-large-message-ok"></button>
</menu>
</form>

View file

@ -0,0 +1,3 @@
<div class="msg-peep-bubble peep-bubble" dir="auto" role="option">
<span class="msg-peep-content"></span>
</div>

View file

@ -0,0 +1,15 @@
<form class="msg-reply-menu" role="dialog" data-type="action">
<header></header>
<menu>
<button class="msg-reply-menu-reply" data-l10n-id="message-reply-menu-reply">
</button>
<button class="msg-reply-menu-reply-all" data-l10n-id="message-reply-menu-reply-all">
</button>
<button class="msg-reply-menu-forward" data-l10n-id="message-reply-menu-forward">
</button>
<span class="last-button-container">
<button class="msg-reply-menu-cancel" data-l10n-id="message-reply-menu-cancel">
</button>
</span>
</menu>
</form>

View file

@ -0,0 +1,228 @@
'use strict';
define(function(require) {
var queryString = require('query_string');
var services = require('services');
var cards = require('cards');
// All of the oauthSecrets we know.
var oauthSecrets = services.oauth2;
// The secrets for the oauth we're currently doing.
var curOauthSecrets;
// Flag to indicate the work for redeeming the auth code is still in progress,
// so detection of the auth window closing should not cancel the operation.
var redeemingCode = false;
var TIMEOUT_MS = 30 * 1000;
var redirectUri = 'http://localhost';
var deferred, p, oauthSettings;
/**
* The postMessage listener called from redirect.html to indicate the final
* data returned from the oauth jump.
* @param {DOMEvent} event the event sent via redirect.html's postMessage.
*/
function onBrowserComplete(message) {
var data = message.data;
if (message.type === 'oauth2Complete') {
if (!data.code) {
reset('reject', new Error('no code returned'));
return;
}
// Okay, so we've got a code. And now we need to redeem that code for
// tokens.
console.log('oauth redirect returned with code');
redeemingCode = true;
redeemCode(data.code).then(
function(redeemed) {
// Microsoft provides expires_on too but Google only provides
// expires_in, so let's just use expires_in for now.
var expiresInMS = parseInt(redeemed.expires_in, 10) * 1000;
// Make the time absolute and let's take 30 seconds off the end to
// allow for ridiculous latency.
var expireTimeMS = Date.now() +
Math.max(0, expiresInMS - TIMEOUT_MS);
var result = {
status: 'success',
tokens: {
accessToken: redeemed.access_token,
refreshToken: redeemed.refresh_token,
expireTimeMS: expireTimeMS
},
secrets: curOauthSecrets
};
reset('resolve', result);
},
function(err) {
reset('reject', err);
});
} else {
// Treat anything else like a cancel, it failed or was stopped by the
// user.
reset('resolve', { status: 'cancel' });
}
}
/**
* Turns the code received from the oauth jump into an actual oauth token
* data.
* @param {String} code the code received from the oauth2 provider.
* @return {Promise}
*/
function redeemCode(code) {
console.log('redeeming oauth code');
return new Promise(function(xhrResolve, xhrReject) {
var xhr = new XMLHttpRequest({ mozSystem: true });
xhr.open('POST', oauthSettings.tokenEndpoint, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.timeout = TIMEOUT_MS;
xhr.onload = function() {
if (xhr.status < 200 || xhr.status >= 300) {
console.error('token redemption failed', xhr.status,
xhr.responseText);
xhrReject('status' + xhr.status);
} else {
try {
// yeah, we could have just set responseType.
var data = JSON.parse(xhr.responseText);
console.log('oauth code redeemed. access_token? ' +
!!data.access_token + ', refresh_token? ' +
!! data.refresh_token);
xhrResolve(data);
} catch (ex) {
console.error('badly formed JSON response for token redemption:',
xhr.responseText);
xhrReject(ex);
}
}
};
xhr.onerror = function(err) {
console.error('token redemption weird error:', err);
xhrReject(err);
};
xhr.ontimeout = function() {
console.error('token redemption timeout');
xhrReject('timeout');
};
var redemptionArgs = {
code: code,
client_id: curOauthSecrets.clientId,
client_secret: curOauthSecrets.clientSecret,
// uh, this doesn't seem right, but the docs want it? hopefully it just
// gets forever ignored?
redirect_uri: redirectUri,
grant_type: 'authorization_code'
};
xhr.send(queryString.fromObject(redemptionArgs));
});
}
function reset(actionName, value) {
console.log('oauth2 fetch reset with action: ' + actionName);
var action = deferred[actionName];
deferred = p = null;
redeemingCode = false;
action(value);
}
/**
* Does the OAuth dialog dance.
*
* @param {Object} o2Settings
* @param {String} o2Settings.authEndpoint
* URL of the authorization endpoint.
* @param {String} o2Settings.scope
* The scope to request.
* @param {String} [o2Settings.secretGroup]
* Secret group identifier if this is an initial OAuth. If this is a reauth
* then the clientId should be provided instead and reused.
* @param {String} [o2Settings.clientId]
* If this is a reauth (or don't need to/want to use our internal secret
* table, the client_id to reuse. clientSecret should also be provided
* (even though we don't use it ourselves) for consistency.
* @param {Object} [extraQueryObject] extra query args to pass to the
* provider. These are typically only known at runtime vs the o2Settings.
* The property names in the extraQueryObject will be mapped to the provider's
* preferred names by using o2Settings.extraArgsMap
*
* @return {Promise} The resolved value will be an object with these
* properties:
* * status: String indicating status of the request. 'succcess' and 'cancel'
* are the possible values.
* * tokens: If status is 'success', this will be the token data in the form
* { accessToken, refreshToken, expireTimeMS }
* * secrets: The clientId and clientSecret used for this oauth.
*/
return function oauth2Fetch(o2Settings, extraQueryObject) {
// Only one auth session at a time.
if (deferred) {
reset('reject', new Error('Multiple oauth calls, starting new one'));
}
p = new Promise(function(res, rej) {
deferred = {
resolve: res,
reject: rej
};
});
oauthSettings = o2Settings;
curOauthSecrets = undefined;
if (o2Settings.clientId && o2Settings.clientSecret) {
curOauthSecrets = {
clientId: o2Settings.clientId,
clientSecret: o2Settings.clientSecret
};
} else if (o2Settings.secretGroup) {
curOauthSecrets = oauthSecrets[o2Settings.secretGroup];
}
if (!curOauthSecrets) {
reset('reject',
new Error('no secrets for group: ' + o2Settings.secretGroup));
return p;
}
var authEndpointQuery = {
client_id: curOauthSecrets.clientId,
// This must match what's in our manifest so we're not considering it
// something that's allowed to change. Because this is currently
// fairly gmail specific with the "installed app" flow, we (must) use
// localhost, but we are maintaining a redirect for our theoretical
// https://email.gaiamobile.org/ domain.
redirect_uri: redirectUri,
response_type: 'code',
scope: o2Settings.scope,
// max_auth_age set to 0 means that google will not keep the user signed
// in after completing the oauth jump. This is important so that if an
// account is deleted, someone else could not then just set up another
// account using the cached sign in cookies.
max_auth_age: 0
};
var url = o2Settings.authEndpoint + '?' +
queryString.fromObject(authEndpointQuery);
if (extraQueryObject) {
var extraArgs = queryString.fromObject(extraQueryObject);
if (extraArgs) {
url += '&' + extraArgs;
}
}
cards.pushCard('setup_oauth2', 'animate', {
url: url,
onBrowserComplete: onBrowserComplete
});
return p;
};
});

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,145 @@
<!-- Per-account settings menu; largely read-only for now -->
<!-- need this section element to keep building blocks happy. Too bad it
expects a section element -->
<section class="skin-organic bbshim settings-account-common" role="region">
<header class="tng-account-header">
<a data-event="click:onBack"
href="#" class="tng-back-btn back-btn header-start-btn"
role="button" data-l10n-id="back-button">
<span class="icon icon-back"></span>
</a>
<h1 data-prop="headerLabel"
class="tng-account-header-label header-label"></h1>
</header>
<section class="scrollregion-below-header skin-organic" role="region">
<header class="collapsed"></header>
<header class="tng-main-accounts-label">
<h2 data-l10n-id="settings-sub-header-account">
AccounT
</h2>
</header>
<ul class="tng-list" data-l10n-id="settings-account-group">
<li class="list-text-with-sub">
<span data-l10n-id="settings-account-name" class="list-text">
YouR NamE</span>
<span data-prop="accountNameNode" dir="auto"
class="tng-account-name list-sub-text"></span>
</li>
<li>
<label class="tng-notify-mail-label pack-switch list-pack-switch">
<input class="tng-notify-mail" type="checkbox" />
<span data-l10n-id="settings-account-display-notifications"
class="list-text list-pack-switch-text">
DisplaY NotificationS FoR NeW MessageS</span>
</label>
</li>
<li>
<label class="tng-sound-onsend-label pack-switch list-pack-switch">
<input class="tng-sound-onsend" type="checkbox" />
<span data-l10n-id="settings-account-play-sound-onsend"
class="list-text list-pack-switch-text">
PlaY SounD AfteR MessagE SenT</span>
</label>
</li>
<li>
<label data-prop="defaultLabelNode"
class="pack-checkbox tng-default-label list-pack-checkbox">
<input data-prop="defaultInputNode"
class="tng-default-input" type="checkbox">
<span data-l10n-id="settings-default-account"
class="list-text list-pack-checkbox-text">
SeT AS DefaulT EmaiL</span>
</label>
</li>
<li class="settings-signature settings-input-list">
<label class="pack-switch tng-default-label list-pack-switch">
<input class="tng-signature-input" type="checkbox">
<span data-l10n-id="settings-account-signature-label"
class="list-text list-pack-switch-text">
SignaturE</span>
</label>
<button class="signature-button button icon">
<div dir="auto" class="signature-button-content"></div>
</button>
</li>
</ul>
<header class="tng-main-accounts-label">
<h2 data-l10n-id="settings-sub-header-data">
DatA
</h2>
</header>
<ul class="tng-list" data-l10n-id="settings-data-group">
<li class="syncinterval-setting select-item">
<label data-l10n-id="settings-account-check-mail" class="list-text">
ChecK FoR NeW MessageS</label>
<span class="button icon icon-dialog">
<select class="tng-account-check-interval mail-select"
data-l10n-id="settings-account-check-mail-select">
<option value="0" data-l10n-id="settings-check-every-manual">
ManuallY</option>
<option value="300000" data-l10n-id="settings-check-every-5min">
5 MinuteS</option>
<option value="600000" data-l10n-id="settings-check-every-10min">
10 MinuteS</option>
<option value="900000" data-l10n-id="settings-check-every-15min">
15 MinuteS</option>
<option value="1800000" data-l10n-id="settings-check-every-30min">
30 MinuteS</option>
<option value="3600000" data-l10n-id="settings-check-every-60min">
60 MinuteS</option>
</select>
</span>
</li>
<li data-prop="syncSettingNode" class="synchronize-setting select-item">
<label data-l10n-id="settings-account-synchronize-window"
class="list-text"></label>
<span class="button icon icon-dialog">
<select data-prop="synchronizeNode"
data-event="change:onChangeSynchronize"
class="mail-select tng-account-synchronize"
data-l10n-id="settings-account-synchronize-window-select">
<option value="auto" data-l10n-id="settings-synchronize-auto">
AutomatiC</option>
<option value="1d" data-l10n-id="settings-synchronize-one-day">
1 DaY</option>
<option value="3d" data-l10n-id="settings-synchronize-three-days">
3 DayS</option>
<option value="1w" data-l10n-id="settings-synchronize-one-week">
1 WeeK</option>
<option value="2w" data-l10n-id="settings-synchronize-two-weeks">
2 WeekS</option>
<option value="1m" data-l10n-id="settings-synchronize-one-month">
1 MontH</option>
<option value="all" data-l10n-id="settings-synchronize-all">
AlL MessageS</option>
</select>
</span>
</li>
</ul>
<header class="tng-main-accounts-label">
<h2 data-l10n-id="settings-sub-header-server">
ServeR SettingS
</h2>
</header>
<ul data-prop="serversContainer" data-l10n-id="settings-server-group"
class="tng-list tng-account-server-container">
<li class="item-with-children">
<a href="#" data-prop="accountCredNode"
data-event="click:onClickCredentials"
class="tng-account-credentials list-text"></a>
</li>
<li class="list-text-pair">
<span data-l10n-id="settings-account-type" role="presentation"
class="list-text aside start">
AccounT TypE</span>
<div role="presentation">
<span data-prop="accountTypeNode" dir="auto" role="presentation"
class="tng-account-type"></span>
</div>
</li>
</ul>
<button href="#" data-l10n-id="settings-account-delete"
data-event="click:onDelete"
class="tng-account-delete danger"></button>
</section>
</section>

View file

@ -0,0 +1,148 @@
'use strict';
define(function(require) {
var tngAccountSettingsServerNode =
require('tmpl!./tng/account_settings_server.html'),
tngAccountDeleteConfirmNode =
require('tmpl!./tng/account_delete_confirm.html'),
evt = require('evt'),
mozL10n = require('l10n!'),
cards = require('cards'),
ConfirmDialog = require('confirm_dialog');
return [
require('./base_card')(require('template!./settings_account.html')),
require('./account_prefs_mixins'),
{
onArgs: function(args) {
this.account = args.account;
this.identity = this.account.identities[0];
this.headerLabel.textContent = args.account.name;
this._bindPrefs('tng-account-check-interval',
'tng-notify-mail',
'tng-sound-onsend',
'tng-signature-input',
'signature-button');
this.accountNameNode.textContent =
(this.identity && this.identity.name) || this.account.name;
// ActiveSync, IMAP and SMTP are protocol names, no need to be localized
this.accountTypeNode.textContent =
(this.account.type === 'activesync') ? 'ActiveSync' :
(this.account.type === 'imap+smtp') ? 'IMAP+SMTP' : 'POP3+SMTP';
// Handle default account checkbox. If already a default, then the
// checkbox cannot be unchecked. The default is changed by going to an
// account that is not the default and checking that checkbox.
if (this.account.isDefault) {
this.defaultInputNode.disabled = true;
this.defaultInputNode.checked = true;
} else {
this.defaultLabelNode.addEventListener('click',
this.onChangeDefaultAccount.bind(this),
false);
}
if (this.account.type === 'activesync') {
this.synchronizeNode.value = this.account.syncRange;
} else {
// Remove it from the DOM so that css selectors for last-child can work
// efficiently. Also, it just makes the overall DOM smaller.
this.syncSettingNode.parentNode.removeChild(this.syncSettingNode);
}
this.account.servers.forEach(function(server, index) {
var serverNode = tngAccountSettingsServerNode.cloneNode(true);
var serverLabel = serverNode.querySelector('.tng-account-server-label');
mozL10n.setAttributes(serverLabel,
'settings-' + server.type + '-label');
serverLabel.addEventListener('click',
this.onClickServers.bind(this, index), false);
this.serversContainer.appendChild(serverNode);
}.bind(this));
var credL10nId = 'settings-account-userpass';
if (this.account.authMechanism === 'oauth2') {
credL10nId = 'settings-account-useroauth2';
}
mozL10n.setAttributes(this.accountCredNode, credL10nId);
},
onBack: function() {
cards.removeCardAndSuccessors(this, 'animate', 1);
},
onCardVisible: function() {
this.updateSignatureButton();
},
onClickCredentials: function() {
cards.pushCard(
'settings_account_credentials', 'animate',
{
account: this.account
},
'right');
},
onClickServers: function(index) {
cards.pushCard(
'settings_account_servers', 'animate',
{
account: this.account,
index: index
},
'right');
},
onChangeDefaultAccount: function(event) {
event.stopPropagation();
if (event.preventBubble) {
event.preventBubble();
}
if (!this.defaultInputNode.disabled) {
this.defaultInputNode.disabled = true;
this.defaultInputNode.checked = true;
this.account.modifyAccount({ setAsDefault: true });
}
},
onChangeSynchronize: function(event) {
this.account.modifyAccount({syncRange: event.target.value});
},
onDelete: function() {
var account = this.account;
var dialog = tngAccountDeleteConfirmNode.cloneNode(true);
var content = dialog.getElementsByTagName('p')[0];
mozL10n.setAttributes(content, 'settings-account-delete-prompt',
// Replacing the '@' character with '\u200b@\u200b'
// where \u200b is a zero-width space allows for line-breaking
{ account: account.name.replace(/@/, '\u200b@\u200b') });
ConfirmDialog.show(dialog,
{ // Confirm
id: 'account-delete-ok',
handler: function() {
account.deleteAccount();
evt.emit('accountDeleted', account);
}
},
{ // Cancel
id: 'account-delete-cancel',
handler: null
}
);
},
die: function() {
}
}
];
});

View file

@ -0,0 +1,55 @@
<!-- Per-account credentials settings menu -->
<!-- need this section element to keep building blocks happy. Too bad it
expects a section element -->
<section class="skin-organic bbshim" role="region">
<header class="tng-account-server-header">
<a data-event="click:onBack"
href="#" class="tng-back-btn back-btn header-start-btn"
role="button" data-l10n-id="back-button">
<span class="icon icon-back"></span>
</a>
<h1 data-prop="headerLabel"
class="tng-account-header-label header-label"></h1>
</header>
<section class="scrollregion-below-header skin-organic" role="region">
<header class="collapsed"></header>
<header>
<h2 data-l10n-id="settings-account-credentials"
class="tng-account-credentials-label"></h2>
</header>
<div class="tng-form">
<li class="tng-account-server-username settings-input-list">
<h2 data-l10n-id="settings-username"
class="tng-server-username-title" aria-level="3">
UsernamE</h2>
<input disabled data-prop="usernameNodeInput"
dir="auto"
class="tng-server-username-input" type="text" />
</li>
<li class="tng-account-server-password settings-input-list">
<h2 data-l10n-id="settings-password"
class="tng-server-password-title" aria-level="3">
PassworD</h2>
<!-- When the input is focused, a resize event will be fired
and will trigger _showCard() in mail-common.js
the UI state will become wrong after _showCard() is called
so disable the input first to prevent the wrong state
-->
<input data-prop="passwordNodeInput"
class="tng-server-password-input" type="password"
data-l10n-id="setup-new-password" />
</li>
</div>
<button data-l10n-id="settings-save"
data-prop="saveButton"
data-event="click:onClickSave"
class="tng-account-save settings-button recommend"></button>
<button data-l10n-id="settings-reauth"
data-prop="reauthButton"
data-event="click:onClickReauth"
class="tng-account-reauth settings-button recommend collapsed">
</button>
</section>
</section>

View file

@ -0,0 +1,74 @@
'use strict';
define(function(require) {
var mozL10n = require('l10n!'),
cards = require('cards'),
oauthFetch = require('./oauth2/fetch');
return [
require('./base_card')
(require('template!./settings_account_credentials.html')),
{
onArgs: function(args) {
this.account = args.account;
this.headerLabel.textContent = this.account.name;
// If we're not using password auth, then hide the password box and the
// save UI for it.
if (this.account.authMechanism !== 'password') {
this.querySelector('.tng-account-server-password')
.classList.add('collapsed');
this.saveButton.classList.add('collapsed');
}
// If the account is explicitly an oauth2 type, then show the reauthorize
// button.
if (this.account.authMechanism === 'oauth2') {
this.reauthButton.classList.remove('collapsed');
}
this.usernameNodeInput.value = this.account.username;
},
onBack: function() {
cards.removeCardAndSuccessors(this, 'animate', 1);
},
onClickSave: function() {
var password = this.passwordNodeInput.value;
if (password) {
this.account.modifyAccount({password: password});
this.account.clearProblems();
} else {
alert(mozL10n.get('settings-password-empty'));
}
this.onBack();
},
onClickReauth: function() {
var oauth2 = this.account._wireRep.credentials.oauth2;
oauthFetch(oauth2, {
login_hint: this.account.username
})
.then(function(response) {
if (response.status === 'success') {
this.account.modifyAccount({ oauthTokens: response.tokens });
// The user may have reauthed because they canceled an onbadlogin
// card but came here to try to fix the problem, so ask to clear
// problems if possible.
this.account.clearProblems();
// Successfully reauthed, nothing else to do on this card.
this.onBack();
}
}.bind(this));
},
die: function() {
}
}
];
});

View file

@ -0,0 +1,43 @@
<!-- Per-account server settings menu -->
<!-- need this section element to keep building blocks happy. Too bad it
expects a section element -->
<section class="skin-organic bbshim" role="region">
<header class="tng-account-server-header">
<a data-event="click:onBack"
href="#" class="tng-back-btn back-btn header-start-btn"
role="button" data-l10n-id="back-button">
<span class="icon icon-back"></span>
</a>
<h1 data-prop="headerLabel"
class="tng-account-header-label header-label">
AccounT NamE</h1>
</header>
<section class="scrollregion-below-header skin-organic" role="region">
<header class="collapsed"></header>
<header>
<h2 data-prop="serverLabel"
class="tng-account-server-label"></h2>
</header>
<div class="tng-form">
<li class="tng-account-server-hostname settings-input-list">
<h2 data-l10n-id="settings-hostname"
class="tng-server-hostname-title" aria-level="3">
HostnamE</h2>
<input data-prop="hostnameNodeInput"
disabled class="tng-server-hostname-input"
type="text" />
</li>
<li class="tng-account-server-port settings-input-list">
<h2 data-l10n-id="settings-port"
class="tng-server-port-title" aria-level="3"></h2>
<input data-prop="portNodeInput" disabled class="tng-server-port-input"
type="text" />
</li>
</div>
<!-- nothing is editable, nothing is saved; collapse. -->
<button data-l10n-id="settings-save"
data-event="click:onBack"
class="tng-account-save settings-button recommend collapsed"
></button>
</section>
</section>

View file

@ -0,0 +1,39 @@
/**
* Per-account server settings, it can be activesync, imap+smtp, or
* pop3+smtp
*/
'use strict';
define(function(require) {
var mozL10n = require('l10n!'),
cards = require('cards');
return [
require('./base_card')
(require('template!./settings_account_servers.html')),
{
onArgs: function(args) {
this.account = args.account;
this.server = args.account.servers[args.index];
this.headerLabel.textContent = this.account.name;
mozL10n.setAttributes(this.serverLabel,
'settings-' + this.server.type + '-label');
// activesync stores its data in 'server'
this.hostnameNodeInput.value = this.server.connInfo.hostname ||
this.server.connInfo.server;
// port is meaningless for activesync; display empty value
this.portNodeInput.value = this.server.connInfo.port || '';
},
onBack: function() {
cards.removeCardAndSuccessors(this, 'animate', 1);
},
die: function() {
}
}
];
});

View file

@ -0,0 +1,70 @@
<!-- Secret developer debug page; for those in the know. This page can only be
reached by tapping 5 times on some whitespace on the main settings page.
NOTE NOTE NOTE NOTE NOTE
This page is intentionally unlocalized. There's a few reasons for this:
- The intended users are Firefox OS developers for which English is
currently a pre-requisite.
- It allows us to change these settings without being particularly
concerned about string freeze.
- We don't have to worry about the localized strings not capturing the
risk of enabling this logging.
This raises the question: Should this be shipped in non-developer builds if
we think it's not worth localizing? Our current rationale is that it is
useful. We (the developers) have had bug reporters enable the logging and
worked with us to fix bugs using the functionality. Especially given our
limited ability to provide updated versions of the app or have users
side-load versions of our certified app.
-->
<!-- This section element is needed by building blocks, would be nice if
bb used a class instead of a section element target -->
<section class="skin-organic settings-account-common bbshim" role="region">
<header class="tng-account-header">
<a data-event="click:onClose"
href="#" class="tng-back-btn back-btn header-start-btn">
<span class="icon icon-back"></span>
</a>
<h1 class="header-label">Debug Magic</h1>
</header>
<section class="scrollregion-below-header skin-organic" role="region">
<button data-event="click:resetApp"
href="#" class="tng-dbg-reset">Reset App</button>
<header><h2>Logging</h2></header>
<ul class="tng-list">
<li class="select-item">
<label class="list-text">Logging Mode</label>
<span class="button icon icon-dialog">
<select data-prop="loggingSelect"
data-event="change:onChangeLogging"
class="tng-dbg-logging">
<option value="">
No logging.
</option>
<option value="safe">
Circular: Safe data: no passwords or message contents logged.
</option>
<option value="dangerous">
Circular: Dangerous data: passwords and message contents logged.
</option>
<option value="realtime-dangerous">
Realtime + Circular: Dangerous data: passwords and message contents logged.
</option>
</select>
</span>
</li>
</ul>
<button data-event="click:dumpLog"
href="#"
class="tng-dbg-dump-storage"
>Dump circular log buffer to sdcard</button>
<header><h2>More Settings!</h2></header>
<button data-event="click:fastSync"
href="#"
class="tng-dbg-fastsync">Enable faster sync options</button>
<button data-event="click:resetStartupCache"
href="#">Reset startup cache</button>
</section>
</section>

View file

@ -0,0 +1,58 @@
'use strict';
var _secretDebug;
/**
* Quasi-secret card for troubleshooting/debugging support. Not part of the
* standard UX flow, potentially not to be localized, and potentially not to
* be shipped after initial dogfooding.
*/
define(function(require) {
var MailAPI = require('api'),
cards = require('cards'),
htmlCache = require('html_cache');
if (!_secretDebug) {
_secretDebug = {};
}
return [
require('./base_card')
(require('template!./settings_debug.html')),
{
createdCallback: function() {
this.loggingSelect.value = MailAPI.config.debugLogging || '';
},
onClose: function() {
cards.removeCardAndSuccessors(this, 'animate', 1);
},
resetApp: function() {
window.location.reload();
},
dumpLog: function(target) {
MailAPI.debugSupport('dumpLog', target);
},
onChangeLogging: function() {
// coerce the falsey empty string to false.
var value = this.loggingSelect.value || false;
MailAPI.debugSupport('setLogging', value);
},
fastSync: function() {
_secretDebug.fastSync = [20000, 60000];
},
resetStartupCache: function() {
htmlCache.reset();
console.log('htmlCache.reset done');
},
die: function() {
}
}
];
});

View file

@ -0,0 +1,26 @@
<!-- Main settings menu, root of all (mail) settings -->
<!-- need this section element to keep building blocks happy. Too bad it
expects a section element -->
<section class="skin-organic bbshim" role="region">
<header class="tng-main-header">
<menu data-event="click:onClose" type="toolbar" class="tng-close-btn">
<button data-l10n-id="settings-done"></button>
</menu>
<h1 class="tng-main-header-label"
data-l10n-id="settings-main-header"></h1>
</header>
<section class="scrollregion-below-header skin-organic" role="region">
<header class="collapsed"></header>
<header class="tng-main-accounts-label">
<h2 data-l10n-id="settings-account-section"></h2>
</header>
<ul data-prop="accountsContainer" class="tng-accounts-container"
data-l10n-id="settings-account-listbox" role="listbox">
</ul>
<button data-event="click:onClickAddAccount"
href="#" data-l10n-id="settings-account-add"
class="tng-account-add"></button>
<a data-event="click:onClickSecretButton"
class="tng-email-lib-version list-text">v1</a>
</section>
</section>

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