privly/privly-applications

View on GitHub
shared/javascripts/viewAdapters/show.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * @fileOverview This is a general adapter for interfacing with the Privly-web
 * server: github.com/privly/privly-web
 *
 * Its purpose is to allow all privly-web derived applications to interface
 * with privly-web through a consistent adapter, thereby speeding updates
 * and easing the generation of a large number of applications.
 *
 * It manages the general flow of an application that interfaces with the 
 * privly-web server but it is intended to be extended as necessary by
 * individual privly-applications. For example, see show.js in the PlainPost
 * Privly Application.
 *
 * ## Expected URL Elements ##
 * This adapter expects the URL associated with the application to have
 * parameters specifying where the application's data resides.
 * 'privlyDataURL': Where the source data is stored.
 *
 *
 * ## Expected DOM Elements ##
 * This adapter expects the DOM to have the set of elements defined below.
 * '.destroy_link': This is the link to destroy the content.
 * '#cancel_button': This is the link to cancel the updating process.
 * '.edit_link': This is the link to start the updating process.
 * '#post_content': This is the content displayed to the end user.
 * '#messages': These are UI messages presented to the user.
 * '#login_message': This is the message telling the user they need to login.
 * '#no_permissions_nav': The navigation that is shown if the user has no 
                          permissions on the content.
 * '#permissions_nav': The navigation that is shown if the user has permissions
                       on the content.
 * '#destruction_select_block': The editing form's desruction timeframe 
                                select block.
 * '#current_destruction_time': When the content will be destroyed on the server.
 * '#seconds_until_burn': The number of seconds until the content is destroyed.
 * '#edit_form': The form for editing the content.
 * '#edit_text': The text in the text area for editing.
 * '.meta_created_at': A block containing when content was created.
 * '.meta_created_at_value': The timedate when content was created.
 * '.meta_destroyed_around': When the content will be destroyed.
 *
 * ## Extending this script ##
 * Each of these functions have callbacks that will be called at the
 * end of the function, if the callback is defined.
 * If you need to modify the inputs to these callbacks or to perform an 
 * action before the callback is executed, we recommend you overwrite the
 * callback with a new callback that calls the original callback with the
 * modified parameters. For example:
 *
 * var oldCallback = callbacks.postCompleted;
 * callbacks.postCompleted = function(response) {
 *   oldCallback(response, "modified");
 * }
 *
 **/
/* jshint undef: true, unused: true */
/* global privlyParameters, $, privlyNetworkService, privlyHostPage */
/* global privlyTooltip, loadInjectedCSS, loadInjectedJS, loadTopCSS, loadTopJS */

/**
 * @namespace
 *
 * State variables used accross all the callbacks.
 *
 */
var state = {

  /**
  * The parameters found on the app's URL as parsed by the parameter script.
  */
  parameters: {},

  /**
  * The URL of the application when accessed via the remote server. This
  * parameter is usually assigned by the extension since the original URL
  * is replaced by one served from the extension.
  */
  webApplicationURL: "",

  /**
  * The URL of the data endpoint for this application.
  */
  jsonURL: "",

  /**
  * Reference to the setTimeout so that we can clear it
  * in case user double clicks for inline editing
  **/
  timeoutRef: "",

  /**
  * Variable to check if the iframe has been clicked before
  * for inline editing
  **/
  isClicked: false,
  
  /**
  * Variable to check if the editing is done inline or not.
  * 
  **/
  isInlineEdit: false
};

/**
 * The callbacks assign the state of the application.
 *
 * This application can be placed into the following states:
 * 1. Pending Content: The app is currently requesting the content.
 *    Callback=pendingContent
 * 2. Pending Login: The user needs to login to the server storing the
 *    content. After login, they may have access.
 *    Callback=pendingLogin
 * 3. Content Returned: The server returned the content for display.
 *    Callback=contentReturned
 * 4. Destroy: The user clicked the "destroy" button.
 * 5. Destroyed: The server returned a response from the destroy command.
 * 6. Edit: The user clicked the "edit" button.
 * 7. Update: The user submitted the "edit" form.
 * 8. click: The user clicked the application. This is primarily used when
 *    the application is injected into the context of a host page.
 *    Callback=click
 */

var callbacks = {

  /**
   * Initialize the whole application.
   * @param {function} callback The function that will be executed after
   * content is returned from the remote server. This function will be
   * called with the response object as a parameter.
   */
  pendingContent: function(callback) {

    // Show prompt to download extension, if in the hosted environment
    callbacks.showDownloadMessage();

    // Set the application and data URLs
    var href = window.location.href;
    state.webApplicationURL = privlyParameters.getApplicationUrl(href);
    state.parameters = privlyParameters.getParameterHash(state.webApplicationURL);
    state.jsonURL = state.webApplicationURL;
    if (state.parameters.privlyDataURL !== undefined) {
      state.jsonURL = state.parameters.privlyDataURL;
    }

    // Display the data source to the user
    var domainSelector = document.createElement('a');
    domainSelector.href = state.jsonURL;
    $(".meta_source_domain").text(domainSelector.hostname);

    // Display the data source to the user. Highlight the link on click.
    $(".meta_source_url")
      .text(state.webApplicationURL)
      .attr("href", state.webApplicationURL)
      .click(function (ev) {
        var range, selection;
        if (window.getSelection && document.createRange) {
          selection = window.getSelection();
          range = document.createRange();
          range.selectNodeContents($(this)[0]);
          selection.removeAllRanges();
          selection.addRange(range);
        } else if (document.selection && document.body.createTextRange) {
          range = document.body.createTextRange();
          range.moveToElementText($(this)[0]);
          range.select();
        }
        ev.preventDefault();
      });

    // Register the click listener.
    $("#post_content").on("click", callbacks.click);
    $("#edit_form").on("click", callbacks.click);

    // Register the link and button listeners.
    $(".destroy_link").click(callbacks.destroy);
    
    // Watch the update button and pass the content callback
    // to the update function
    document.getElementById("update").addEventListener('click', 
      function(evt){callbacks.update(evt, callback);});
    $(".edit_link").click(callbacks.edit);

    // Set the nav bar to the proper domain
    privlyNetworkService.initializeNavigation();

    if(privlyHostPage.isInjected()) {

      // Force links to open in a new window/tab
      var openBlank = document.createElement("base");
      openBlank.setAttribute("target", "_blank");
      document.getElementsByTagName('head')[0].appendChild(openBlank);

      // Creates a tooptip which indicates the content is not a 
      // natural element of the page
      privlyTooltip.tooltip();

      // Send the height of the iframe everytime the window size changes.
      // This usually results from the user resizing the window.
      // This causes performance issues on Firefox.
      if( privlyNetworkService.platformName() !== "FIREFOX" ) {
        $(window).resize(function(){
          privlyHostPage.resizeToWrapper();
        });
      }
      
      // Display the domain of the content in the glyph
      var dataDomain = privlyNetworkService.getProtocolAndDomain(state.jsonURL);
      privlyTooltip.updateMessage(dataDomain, "Read Only");
      
      // Load CSS to show the tooltip and other injected styling
      loadInjectedCSS();
      loadInjectedJS();
    } else {

      // Check whether the user is signed into their content server
      privlyNetworkService.initPrivlyService(
        privlyNetworkService.contentServerDomain(), 
        privlyNetworkService.showLoggedInNav, 
        function(){}, // otherwise assume they are logged out
        function(){} // otherwise assume they are logged out
      );

      // Load CSS to show the nav and the rest of the non-injected page
      loadTopCSS();
      loadTopJS();
    }

    // Ensure whitelist compliance of the data parameter when the content is
    // injected
    if( !privlyHostPage.isInjected() ||
        privlyNetworkService.isWhitelistedDomain(state.jsonURL) ) {

      // Make the cross origin request as if it were on the same origin.
      // The "same origin" requirement is only possible on extension frameworks
      privlyNetworkService.sameOriginGetRequest(state.jsonURL,
        function(response){callbacks.contentReturned(response, callback);});
    } else if( privlyHostPage.isInjected() ) {
      $("#post_content").html("<p>Click to view this content.</p>");
      if(callbacks.functionExists(callback)) {
        callback();
      }
    } else {
      if(callbacks.functionExists(callback)) {
        callback();
      }
    }
  },
  
  /**
  * The user may have access to the content if they login to the server
  * hosting the content.
  *
  * @param {function} callback The function to execute after this function
  * completes.
  */
  pendingLogin: function(callback) {
    
    $("#messages").hide();
    $("#login_message").show();
    
    $("#post_content").html("<p>You do not have access to this.</p>");
    
    // Tells the parent document how tall the iframe is so that
    // the iframe height can be changed to its content's height
    privlyHostPage.resizeToWrapper();
    
    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
  * Process the post's content returned from the remote server.
  *
  * @param {object} response The response from the remote server. In cases
  * without error, the response body will be in response.response.
  * @param {function} callback The function that gets called after all the
  * permissions and other elemets are set. This function should accept
  * "response" as a paramter.
  */
  contentReturned: function(response, callback) {
    if( response.jqXHR.status === 200 ) {
      
      var json = response.json;
      
      privlyNetworkService.permissions.canShow = true;
      
      // Assign the permissions
      if( json.permissions ) {
        privlyNetworkService.permissions.canShare = (
          json.permissions.canshare === true);
        privlyNetworkService.permissions.canUpdate = (
          json.permissions.canupdate === true);
        privlyNetworkService.permissions.canDestroy = (
          json.permissions.candestroy === true);
      }
      
      // If the user is able to update the content,
      // we should notify the user and pre-fetch
      // the necessary credentials to post to the server
      if( privlyNetworkService.permissions.canUpdate || 
        privlyNetworkService.permissions.canDestroy ) {
          privlyNetworkService.initPrivlyService(
            state.jsonURL, 
            function(){

              // Initialize the form for updating the post
              // if the user has permission.
              if( privlyNetworkService.permissions.canUpdate) {
                $(".edit_link").show();
                $("#no_permissions_nav").hide();
                $("#permissions_nav").show();
                
                $("#cancel_button").on("click", function(evt){
                  callbacks.cancel(evt);
                  callback(response);
                });
                
                var dataDomain = privlyNetworkService.getProtocolAndDomain(state.jsonURL);
                privlyTooltip.updateMessage(dataDomain, "Editable");
                $(".meta_canupdate").show();
              }

              // Initialize the form for destroying the post
              // if the user has permission.
              if( privlyNetworkService.permissions.canDestroy) {
                $(".destroy_link").show();
                $("#no_permissions_nav").hide();
                $("#permissions_nav").show();
                $(".meta_candestroy").show();
                $("#destruction_select_block").show();
              }
            }, 
            function(){}, // otherwise assume no permissions
            function(){} // otherwise assume no permissions
          );
      }
      
      // Set creation date meta
      if( json.created_at ) {
        var createdDate = new Date(json.created_at);
        $(".meta_created_at_value").text(createdDate.toDateString());
        $(".meta_created_at").show();
      }
      
      // Set burnt date meta
      if( json.burn_after_date ) {
        var destroyedDate = new Date(json.burn_after_date);
        $(".meta_destroyed_around").text("Automatically Destroyed Around " +
          destroyedDate.toDateString() + ". ");
        var currentSecondsUntilDestruction = Math.floor((destroyedDate - Date.now())/1000);
        $("#current_destruction_time")
          .attr("value", currentSecondsUntilDestruction)
          .text(Math.floor(currentSecondsUntilDestruction / 86400) + " Days");
        $("#seconds_until_burn").val(currentSecondsUntilDestruction);
      } else {

        // Set the displayed value on the form. The value is already infinity
        $("#current_destruction_time") 
          .text("Infinite");
        $(".meta_destroyed_around").text("This content is not scheduled to destruct.");
      }
      
    } else if(response.jqXHR.status === 403 || response.jqXHR.status === 422) {
      $("#post_content").html(
          "<p class='flash notice'>" +
          "<span class='glyphicon glyphicon-remove-sign' aria-hidden='true'></span> " +
          "Your current user account does not have access to this. " +
          "It is also possible that the content was destroyed at the source.</p>");
    } else {
      $("#post_content").html("<p class='flash notice'>" +
        "<span class='glyphicon glyphicon-remove-sign' aria-hidden='true'></span> " +
        "You do not have access to this.</p>");
    }
    
    // Tells the parent document how tall the iframe is so that
    // the iframe height can be changed to its content's height
    privlyHostPage.resizeToWrapper();
    
    // Send the content to the callback for processing
    if(callbacks.functionExists(callback)) {
      callback(response);
    }
  },
  
  /**
   * The destroy button was just pushed. Ask the remote server to destroy 
   * the content associated with the post, then notify the user of the results
   * in callbacks.destroyed.
   *
   * @param {function} callback A function to be executed when the content is
   * returned from the remote server after destruction. This function is called
   * with the response object after "destroyed" is executed.
   */
  destroy: function(callback) {
    $("#edit_form").slideUp();
    privlyNetworkService.sameOriginDeleteRequest(state.jsonURL, 
      function(response){callbacks.destroyed(response, callback);}, {});
  },
  
  /**
  * Process the content returned from the server on a destroy request.
  *
  * @param {object} response The response from the remote server.
  * @param {function} callback A function to be executed when the content is
  * returned from the remote server after destruction. The callback is passed
  * the response object.
  */
  destroyed: function(response, callback) {
    if( response.jqXHR.status === 200 ) {
      
      // Tell the user the content was probably destroyed
      $("#post_content").html(
        "<p class='flash notice'>The remote server says it destroyed the content. " +
        "If the server cannot be trusted, then it may have copies.</p>");
      
      // Hide the drop down menu
      $("#no_permissions_nav").show();
      $("#permissions_nav").hide();

      // Hide metadata
      $(".meta_canupdate").hide();
      $(".destroy_link").hide();
      $(".meta_candestroy").hide();
      $(".meta_created_at").hide();

      // Tells the parent document how tall the iframe is so that
      // the iframe height can be changed to its content's height
      privlyHostPage.resizeToWrapper();
      
    } else {
      $("#post_content").html("<p>You do not have permission to destroy this.</p>");

      // Tells the parent document how tall the iframe is so that
      // the iframe height can be changed to its content's height
      privlyHostPage.resizeToWrapper();
    }
    
    if(callbacks.functionExists(callback)) {
      callback(response);
    }
  },
  
  /**
   * Display the form for editing the post. This callback is not currently
   * supported in injected mode.
   * @param {function} callback The function to call after showing the edit
   * form.
   */
  edit: function(callback) {
    $("#edit_form").slideDown();
    
    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
   * Update the remote server's content with the new value.
   * This function should only be called if the remote server's
   * initial response had the permission object, and the update
   * flag was set to true. This prevents some CSRF issues.
   *
   * @param {event} evt The event triggered by the update button being clicked.
   * @param {function} callback A function to be executed when the content is
   * returned from the remote server after update. This function is called
   * with the response object.
   */
  update: function(evt, callback) {
    var contentToPost = {post:
      {
        content: $("#edit_text").val(),
        seconds_until_burn: $( "#seconds_until_burn" ).val()
      },
      format:"json"};
    privlyNetworkService.sameOriginPutRequest(state.jsonURL, 
      function(response){callbacks.contentReturned(response, callback);},
        contentToPost);
    
    // needed to stop click event from propagating to body
    // and prevent a new window from opening because of click listener
    // on body.
    evt.stopPropagation();
    
    // Close the editing form
    $("#edit_form").hide();
    state.isInlineEdit = false;

    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
  * This is an event listener for cancel event.
  *
  * @param {event} evt The event triggered by the cancel button
  *  being clicked.
  *
  */
  cancel: function(evt, callback){
    $("#edit_form").hide();
    
    // needed to stop click event from propagating to body
    // and prevent a new window from opening because of click listener
    // on body.
    evt.stopPropagation();
    
    // Resize to its wrapper
    privlyHostPage.resizeToWrapper();
    state.isClicked = false;
    state.isInlineEdit = false;
    
    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
  * This is an event listener for click events. When the applicaiton is
  * injected into the context of a host page, the app will be opened in 
  * a new window.
  *
  * @param {event} evt The event triggered by the window being clicked.
  * @param {function} callback The function to call after the click
  * function is evaluated.
  *
  */
  click: function(evt, callback) {
    
    if( ! privlyNetworkService.permissions.canUpdate ) {
      callbacks.singleClick(evt, callback);
      return;
    }
    
    if (state.isClicked) {
      var target = $(evt.target);
      if (state.isInlineEdit && !target.is("textarea") &&
          !target.is("select")) {
        
        // Double click During inline Editing
        // and not on the textarea or the dropdown.
        callbacks.cancel(evt);
        state.isClicked = false;
      }
      else{
        state.isInlineEdit = true;
        clearTimeout(state.timeoutRef);
        state.isClicked = false;
        callbacks.doubleClick();
      }
    }
    else{
      state.isClicked = true;
      if (state.isInlineEdit) {
        
        // Single Click During inline Editing
        setTimeout(function(){
          state.isClicked = false;
        },200);
      }
      else{
        state.timeoutRef = setTimeout(function(){
          callbacks.singleClick(evt);
        },200);
      }
    }
    
    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
   * This is the handler for single click event on the iframe
   * It opens a new tab with the post content
   *
   * @param {event} evt The click event triggered by the user's interaction.
   * @param {function} callback The function to call after the single click is
   * processed.
   */
  singleClick: function(evt, callback) {
    state.isClicked = false;
    if (privlyHostPage.isInjected()) {
      if (evt.target.nodeName !== "A" || evt.target.href === "") {
       window.open(location.href, '_blank');
     }
   }
   
   if(callbacks.functionExists(callback)) {
     callback();
   }
  },
  
  /**
  * This is the function called when user presses double clicks
  * on the iframe and is used for inline editing.
  *
  * You should also bound doubleclick to canceling the editing
  * mode whenever it is inline editing. This is currently the
  * default.
  *
  * @param {function} callback The function to call after the doubleclick
  * handler is complete.
  *
  */
  doubleClick: function(callback) {
    $("#edit_form").show();
    
    // Hide the Heading when editing inplace
    $("#edit_form h1").hide();
    $('#edit_text').css('width',"95%");
    callbacks.edit();

    // Resize to show the text area update, cancel buttons and burn after
    privlyHostPage.resizeToWrapper();
    
    if(callbacks.functionExists(callback)) {
      callback();
    }
  },

  /**
   * If the application is shown in the hosted context then the download
   * link should be shown.
   *
   * @param {function} callback The function to call after completing this
   * function.
   */
  showDownloadMessage: function(callback) {

    // Show the download extension link in the hosted context
    if( privlyNetworkService.platformName() === "HOSTED" && !privlyHostPage.isInjected() ){

      // Pick which browser logo and link href to display
      var browser = "firefox";
      if (navigator.userAgent.indexOf("Chrome") !== -1){
        browser = "chrome";
      }
      var target = $("#downloadmessage a").data("privly-" + browser);
      $("#downloadmessage a").attr("href", target);
      $("#downloadmessage p a").attr("href", target);

      $("#" + browser + "_img").show(); // show current browser image

      // Determine string of header
      var referrer = document.referrer;
      var msg = "You don't need to ";
      if (referrer === "" || referrer.indexOf(window.location.origin) === 0){
        msg += "visit this page!";
      } else {
        var anchor = document.createElement("a");
        anchor.href = referrer;
        msg += "leave " + anchor.host + "!";
      }
      $(".referrer").text(msg);
      $("#downloadmessage").show();
    }

    if(callbacks.functionExists(callback)) {
      callback();
    }
  },
  
  /**
   * Determines whether a callback is defined before calling it.
   *
   * @param {function} callback Potentially a function.
   * @return {boolean} True if the parameter is a function, else false
   */
  functionExists: function(callback) {
    if (typeof callback === "function") {
      return true;
    }
    return false;
  }
};