From 85e60ada1558798669b29225aa530b4ba9310cdc Mon Sep 17 00:00:00 2001
From: Thomas Bruederli <thomas@roundcube.net>
Date: Sun, 10 Nov 2013 08:04:33 -0500
Subject: [PATCH] First version of the local storage compose data saving feature; some behavioral improvements and encrytion are still to be added

---
 index.php                               |   14 +-
 program/steps/mail/compose.inc          |    6 +
 program/include/rcmail.php              |    3 
 program/localization/en_US/messages.inc |    1 
 program/localization/en_US/labels.inc   |    3 
 program/lib/Roundcube/rcube_user.php    |    8 +
 program/steps/mail/sendmail.inc         |    1 
 program/js/app.js                       |  208 ++++++++++++++++++++++++++++++++++++++++
 skins/larry/ui.js                       |    6 +
 9 files changed, 237 insertions(+), 13 deletions(-)

diff --git a/index.php b/index.php
index 1719abc..03c3f43 100644
--- a/index.php
+++ b/index.php
@@ -193,12 +193,6 @@
     $session_error = true;
   }
 
-  if ($OUTPUT->ajax_call)
-    $OUTPUT->redirect(array('_err' => 'session'), 2000);
-
-  if (!empty($_REQUEST['_framed']))
-    $OUTPUT->command('redirect', $RCMAIL->url(array('_err' => 'session')));
-
   // check if installer is still active
   if ($RCMAIL->config->get('enable_installer') && is_readable('./installer/index.php')) {
     $OUTPUT->add_footer(html::div(array('style' => "background:#ef9398; border:2px solid #dc5757; padding:0.5em; margin:2em auto; width:50em"),
@@ -211,8 +205,14 @@
     );
   }
 
-  if ($session_error || $_REQUEST['_err'] == 'session')
+  if ($session_error || $_REQUEST['_err'] == 'session') {
     $OUTPUT->show_message('sessionerror', 'error', null, true, -1);
+  }
+
+  if ($OUTPUT->ajax_call || !empty($_REQUEST['_framed'])) {
+    $OUTPUT->command('session_error', $RCMAIL->url(array('_err' => 'session')));
+    $OUTPUT->send('iframe');
+  }
 
   $plugin = $RCMAIL->plugins->exec_hook('unauthenticated', array('task' => 'login', 'error' => $session_error));
 
diff --git a/program/include/rcmail.php b/program/include/rcmail.php
index 4b3f137..8abe873 100644
--- a/program/include/rcmail.php
+++ b/program/include/rcmail.php
@@ -413,6 +413,9 @@
     $this->output->set_env('comm_path', $this->comm_path);
     $this->output->set_charset(RCUBE_CHARSET);
 
+    if ($this->user && $this->user->ID)
+      $this->output->set_env('user_id', $this->user->get_hash());
+
     // add some basic labels to client
     $this->output->add_label('loading', 'servererror', 'requesttimedout', 'refreshing');
 
diff --git a/program/js/app.js b/program/js/app.js
index f7fd7ce..81c66ba 100644
--- a/program/js/app.js
+++ b/program/js/app.js
@@ -187,6 +187,8 @@
     if (this.env.permaurl)
       this.enable_command('permaurl', 'extwin', true);
 
+    this.local_storage_prefix = 'roundcube.' + (this.env.user_id || 'anonymous') + '.';
+
     switch (this.task) {
 
       case 'mail':
@@ -578,9 +580,12 @@
     }
 
     // check input before leaving compose step
-    if (this.task == 'mail' && this.env.action == 'compose' && $.inArray(command, this.env.compose_commands)<0) {
+    if (this.task == 'mail' && this.env.action == 'compose' && $.inArray(command, this.env.compose_commands) < 0 && !this.env.server_error) {
       if (this.cmp_hash != this.compose_field_hash() && !confirm(this.get_label('notsentwarning')))
         return false;
+
+      // remove copy from local storage if compose screen is left intentionally
+      this.remove_compose_data(this.env.compose_id);
     }
 
     // process external commands
@@ -615,10 +620,10 @@
         break;
 
       // commands to switch task
+      case 'logout':
       case 'mail':
       case 'addressbook':
       case 'settings':
-      case 'logout':
         this.switch_task(command);
         break;
 
@@ -638,6 +643,7 @@
           var form = this.gui_objects.messageform,
             win = this.open_window('');
 
+          this.save_compose_form_local();
           $("input[name='_action']", form).val('compose');
           form.action = this.url('mail/compose', { _id: this.env.compose_id, _extwin: 1 });
           form.target = win.name;
@@ -1292,8 +1298,10 @@
       return;
 
     var url = this.get_task_url(task);
-    if (task=='mail')
+    if (task == 'mail')
       url += '&_mbox=INBOX';
+    else if (task == 'logout')
+      this.clear_compose_data();
 
     this.redirect(url);
   };
@@ -3117,6 +3125,53 @@
       }
     }
 
+    // check for locally stored compose data
+    if (window.localStorage) {
+      var index = this.local_storage_get_item('compose.index', []);
+
+      for (var key, i = 0; i < index.length; i++) {
+        key = index[i], formdata = this.local_storage_get_item('compose.' + key, null, true);
+        // restore saved copy of current compose_id
+        if (formdata && formdata.changed && key == this.env.compose_id) {
+          this.restore_compose_form(key, html_mode);
+          break;
+        }
+        // show dialog asking to restore the message
+        if (formdata && formdata.changed && formdata.session != this.env.session_id) {
+          this.show_popup_dialog(
+            this.get_label('restoresavedcomposedata')
+              .replace('$date', new Date(formdata.changed).toLocaleString())
+              .replace('$subject', formdata._subject)
+              .replace(/\n/g, '<br/>'),
+            this.get_label('restoremessage'),
+            [{
+              text: this.get_label('restore'),
+              click: function(){
+                ref.restore_compose_form(key, html_mode);
+                ref.remove_compose_data(key);  // remove old copy
+                ref.save_compose_form_local();  // save under current compose_id
+                $(this).dialog('close');
+              }
+            },
+            {
+              text: this.get_label('delete'),
+              click: function(){
+                ref.remove_compose_data(key);
+                $(this).dialog('close');
+              }
+            },
+            {
+              text: this.get_label('cancel'),
+              click: function(){
+                $(this).dialog('close');
+              }
+            }]
+          );
+          break;
+        }
+      }
+    }
+
     if (input_to.val() == '')
       input_to.focus();
     else if (input_subject.val() == '')
@@ -3554,12 +3609,19 @@
 
     this.env.draft_id = id;
     $("input[name='_draft_saveid']").val(id);
+
+    this.remove_compose_data(this.env.compose_id);
   };
 
   this.auto_save_start = function()
   {
     if (this.env.draft_autosave)
       this.save_timer = setTimeout(function(){ ref.command("savedraft"); }, this.env.draft_autosave * 1000);
+
+    // save compose form content to local storage every 10 seconds
+    // TODO: track typing activity and only save on changes
+    if (!this.local_save_timer && window.localStorage)
+      this.local_save_timer = setInterval(function(){ ref.save_compose_form_local(); }, 10000);
 
     // Unlock interface now that saving is complete
     this.busy = false;
@@ -3588,6 +3650,109 @@
 
     return str;
   };
+
+  // store the contents of the compose form to localstorage
+  this.save_compose_form_local = function()
+  {
+    var formdata = { session:this.env.session_id, changed:new Date().getTime() },
+      ed, empty = true;
+
+    // get fresh content from editor
+    if (window.tinyMCE && (ed = tinyMCE.get(this.env.composebody))) {
+      tinyMCE.triggerSave();
+    }
+
+    $('input, select, textarea', this.gui_objects.messageform).each(function(i, elem){
+      switch (elem.tagName.toLowerCase()) {
+        case 'input':
+          if (elem.type == 'button' || elem.type == 'submit' || (elem.type == 'hidden' && elem.name != '_is_html')) {
+            break;
+          }
+          formdata[elem.name] = elem.type != 'checkbox' || elem.checked ? elem.value : '';
+
+          if (formdata[elem.name] != '' && elem.type != 'hidden')
+            empty = false;
+          break;
+
+        case 'select':
+          formdata[elem.name] = $('option:checked', elem).val();
+          break;
+
+        default:
+          formdata[elem.name] = $(elem).val();
+      }
+    });
+
+    if (window.localStorage && !empty) {
+      var index = this.local_storage_get_item('compose.index', []),
+        key = this.env.compose_id;
+
+        if (index.indexOf(key) < 0) {
+          index.push(key);
+        }
+        this.local_storage_set_item('compose.' + key, formdata, true);
+        this.local_storage_set_item('compose.index', index);
+    }
+  };
+
+  // write stored compose data back to form
+  this.restore_compose_form = function(key, html_mode)
+  {
+    var ed, formdata = this.local_storage_get_item('compose.' + key, true);
+
+    if (formdata && typeof formdata == 'object') {
+      $.each(formdata, function(k, value){
+        if (k[0] == '_') {
+          var elem = $("*[name='"+k+"']");
+          if (elem[0] && elem[0].type == 'checkbox') {
+            elem.prop('checked', value != '');
+          }
+          else {
+            elem.val(value);
+          }
+        }
+      });
+
+      // initialize HTML editor
+      if (formdata._is_html == '1') {
+        if (!html_mode) {
+          tinyMCE.execCommand('mceAddControl', false, this.env.composebody);
+          this.triggerEvent('aftertoggle-editor', { mode:'html' });
+        }
+      }
+      else if (html_mode) {
+        tinyMCE.execCommand('mceRemoveControl', false, this.env.composebody);
+        this.triggerEvent('aftertoggle-editor', { mode:'plain' });
+      }
+    }
+  };
+
+  // remove stored compose data from localStorage
+  this.remove_compose_data = function(key)
+  {
+    if (window.localStorage) {
+      var index = this.local_storage_get_item('compose.index', []);
+
+      if (index.indexOf(key) >= 0) {
+        this.local_storage_remove_item('compose.' + key);
+        this.local_storage_set_item('compose.index', $.grep(index, function(val,i){ return val != key; }));
+      }
+    }
+  };
+
+  // clear all stored compose data of this user
+  this.clear_compose_data = function()
+  {
+    if (window.localStorage) {
+      var index = this.local_storage_get_item('compose.index', []);
+
+      for (var i=0; i < index.length; i++) {
+        this.local_storage_remove_item('compose.' + index[i]);
+      }
+      this.local_storage_remove_item('compose.index');
+    }
+  }
+
 
   this.change_identity = function(obj, show_sig)
   {
@@ -6709,6 +6874,20 @@
       setTimeout(function(){ ref.keep_alive(); ref.start_keepalive(); }, 30000);
   };
 
+  // handler for session errors detected on the server
+  this.session_error = function(redirect_url)
+  {
+    this.env.server_error = 401;
+
+    // save message in local storage and do not redirect
+    if (this.env.action == 'compose') {
+      this.save_compose_form_local();
+    }
+    else if (redirect_url) {
+      window.setTimeout(function(){ ref.redirect(redirect_url, true); }, 2000);
+    }
+  };
+
   // callback when an iframe finished loading
   this.iframe_loaded = function(unlock)
   {
@@ -7230,7 +7409,28 @@
   this.set_cookie = function(name, value, expires)
   {
     setCookie(name, value, expires, this.env.cookie_path, this.env.cookie_domain, this.env.cookie_secure);
-  }
+  };
+
+  // wrapper for localStorage.getItem(key)
+  this.local_storage_get_item = function(key, deflt, encrypted)
+  {
+    // TODO: add encryption
+    var item = localStorage.getItem(this.local_storage_prefix + key);
+    return item !== null ? JSON.parse(item) : (deflt || null);
+  };
+
+  // wrapper for localStorage.setItem(key, data)
+  this.local_storage_set_item = function(key, data, encrypted)
+  {
+    // TODO: add encryption
+    return localStorage.setItem(this.local_storage_prefix + key, JSON.stringify(data));
+  };
+
+  // wrapper for localStorage.removeItem(key)
+  this.local_storage_remove_item = function(key)
+  {
+    return localStorage.removeItem(this.local_storage_prefix + key);
+  };
 
 }  // end object rcube_webmail
 
diff --git a/program/lib/Roundcube/rcube_user.php b/program/lib/Roundcube/rcube_user.php
index 57f6336..3e4be0a 100644
--- a/program/lib/Roundcube/rcube_user.php
+++ b/program/lib/Roundcube/rcube_user.php
@@ -221,6 +221,14 @@
         return false;
     }
 
+    /**
+     * Generate a unique hash to identify this user which
+     */
+    function get_hash()
+    {
+        $key = substr($this->rc->config->get('des_key'), 1, 4);
+        return md5($this->data['user_id'] . $key . $this->data['username'] . '@' . $this->data['mail_host']);
+    }
 
     /**
      * Get default identity of this user
diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc
index 8f221a3..92ec826 100644
--- a/program/localization/en_US/labels.inc
+++ b/program/localization/en_US/labels.inc
@@ -232,6 +232,9 @@
 $labels['resumeediting'] = 'Resume editing';
 $labels['revertto']      = 'Revert to';
 
+$labels['restore'] = 'Restore';
+$labels['restoremessage'] = 'Restore message?';
+
 $labels['responses'] = 'Responses';
 $labels['insertresponse'] = 'Insert a response';
 $labels['manageresponses'] = 'Manage responses';
diff --git a/program/localization/en_US/messages.inc b/program/localization/en_US/messages.inc
index 033c820..a36d9ab 100644
--- a/program/localization/en_US/messages.inc
+++ b/program/localization/en_US/messages.inc
@@ -84,6 +84,7 @@
 $messages['nosubjectwarning']  = 'The "Subject" field is empty. Would you like to enter one now?';
 $messages['nobodywarning'] = 'Send this message without text?';
 $messages['notsentwarning'] = 'Message has not been sent. Do you want to discard your message?';
+$messages['restoresavedcomposedata'] = 'A previously composed but unsent message was found.\n\nSubject: $subject\nSaved: $date\n\nDo you want to restore this message?';
 $messages['noldapserver'] = 'Please select an ldap server to search.';
 $messages['nosearchname'] = 'Please enter a contact name or email address.';
 $messages['notuploadedwarning'] = 'Not all attachments have been uploaded yet. Please wait or cancel the upload.';
diff --git a/program/steps/mail/compose.inc b/program/steps/mail/compose.inc
index 646d2bc..7f5435f 100644
--- a/program/steps/mail/compose.inc
+++ b/program/steps/mail/compose.inc
@@ -110,9 +110,10 @@
     'nobodywarning', 'notsentwarning', 'notuploadedwarning', 'savingmessage', 'sendingmessage', 
     'messagesaved', 'converting', 'editorwarning', 'searching', 'uploading', 'uploadingmany',
     'fileuploaderror', 'sendmessage', 'savenewresponse', 'responsename', 'responsetext', 'save',
-    'savingresponse');
+    'savingresponse', 'restoresavedcomposedata', 'restoremessage', 'delete', 'restore');
 
 $OUTPUT->set_env('compose_id', $COMPOSE['id']);
+$OUTPUT->set_env('session_id', session_id());
 $OUTPUT->set_pagetitle(rcube_label('compose'));
 
 // add config parameters to client script
@@ -827,6 +828,9 @@
   $msgtype = new html_hiddenfield(array('name' => '_is_html', 'value' => ($isHtml?"1":"0")));
   $out .= $msgtype->show();
 
+  $framed = new html_hiddenfield(array('name' => '_framed', 'value' => '1'));
+  $out .= $framed->show();
+
   // If desired, set this textarea to be editable by TinyMCE
   if ($isHtml) {
     $MESSAGE_BODY = htmlentities($MESSAGE_BODY, ENT_NOQUOTES, RCMAIL_CHARSET);
diff --git a/program/steps/mail/sendmail.inc b/program/steps/mail/sendmail.inc
index 52b02ec..ea5eaae 100644
--- a/program/steps/mail/sendmail.inc
+++ b/program/steps/mail/sendmail.inc
@@ -855,6 +855,7 @@
     $folders[] = $COMPOSE['mailbox'];
 
   rcmail_compose_cleanup($COMPOSE_ID);
+  $OUTPUT->command('remove_compose_data', $COMPOSE_ID);
 
   if ($store_folder && !$saved)
     $OUTPUT->command('sent_successfully', 'error', rcube_label('errorsavingsent'), $folders);
diff --git a/skins/larry/ui.js b/skins/larry/ui.js
index 660b18f..760cc7a 100644
--- a/skins/larry/ui.js
+++ b/skins/larry/ui.js
@@ -110,7 +110,11 @@
         });
       }
       else if (rcmail.env.action == 'compose') {
-        rcmail.addEventListener('aftertoggle-editor', function(){ window.setTimeout(function(){ layout_composeview() }, 200); });
+        rcmail.addEventListener('aftertoggle-editor', function(e){
+          window.setTimeout(function(){ layout_composeview() }, 200);
+          if (e && e.mode)
+            $("select[name='editorSelector']").val(e.mode);
+        });
         rcmail.addEventListener('aftersend-attachment', show_uploadform);
         rcmail.addEventListener('add-recipient', function(p){ show_header_row(p.field, true); });
 

--
Gitblit v1.9.1