<commit_before>/// <reference path='../../../../third_party/typings/i18next/i18next.d.ts' />

/**
 * ui.ts
 *
 * Common User Interface state holder and changer.
 */

import ui_constants = require('../../interfaces/ui');
import CopyPasteState = require('./copypaste-state');
import CoreConnector = require('./core_connector');
import uproxy_core_api = require('../../interfaces/uproxy_core_api');
import browser_api = require('../../interfaces/browser_api');
import BrowserAPI = browser_api.BrowserAPI;
import net = require('../../../../third_party/uproxy-lib/net/net.types');
import noreConnector = require('./core_connector');
import user_module = require('./user');
import User = user_module.User;
import social = require('../../interfaces/social');
import Constants = require('./constants');
import translator_module = require('./translator');
import _ = require('lodash');
import network_options = require('../../generic/network-options');

var NETWORK_OPTIONS = network_options.NETWORK_OPTIONS;

// Filenames for icons.
// Two important things about using these strings:
// 1) When updating the icon strings below, default values in the Chrome
// manifests and Firefox main.js should also be changed to match.
// 2) These are only the suffixes of the icon names. Because we have
// different sizes of icons, the actual filenames have the dimension
// as a prefix. E.g. "19_online.gif" for the 19x19 pixel version.

export class Model {
  public networkNames :string[] = [];

  public onlineNetworks :Network[] = [];

  public contacts :Contacts = {
    getAccessContacts: {
      pending: [],
      trustedUproxy: [],
      untrustedUproxy: [],
    },
    shareAccessContacts: {
      pending: [],
      trustedUproxy: [],
      untrustedUproxy: [],
    }
  };

  public globalSettings :uproxy_core_api.GlobalSettings = {
    version: 0,
    description: '',
    stunServers: [],
    hasSeenSharingEnabledScreen: false,
    hasSeenWelcome: false,
    splashState : 0,
    mode : ui_constants.Mode.GET,
    allowNonUnicast: false,
    statsReportingEnabled: false,
    consoleFilter: 2, // loggingTypes.Level.warn
    language: 'en',
    force_message_version: 0,
    hasSeenGoogleAndFacebookChangedNotification: false
  };

  public reconnecting = false;

  // userId is included as an optional parameter because we will eventually
  // want to use it to get an accurate network.  For now, it is ignored and
  // serves to remind us of where we still need to add the info
  public getNetwork = (networkName :string, userId?:string) :Network => {
    return _.find(this.onlineNetworks, { name: networkName });
  }

  public removeNetwork = (networkName :string, userId :string) => {
    var network = this.getNetwork(networkName, userId);

    for (var otherUserId in network.roster) {
      var user = this.getUser(network, otherUserId);
      var userCategories = user.getCategories();
      categorizeUser(user, this.contacts.getAccessContacts,
                     userCategories.getTab, null);
      categorizeUser(user, this.contacts.shareAccessContacts,
                     userCategories.shareTab, null);
    }

    _.remove(this.onlineNetworks, { name: networkName });
  }

  public getUser = (network :Network, userId :string) :User => {
    if (network.roster[userId]) {
      return network.roster[userId];
    }

    return null;
  }

  public updateGlobalSettings = (settings :Object) => {
    _.merge(this.globalSettings, settings, (a :any, b :any) => {
      if (_.isArray(a) && _.isArray(b)) {
        return b;
      }

      return undefined;
    });
  }
}

export interface ContactCategory {
  [type :string] :User[];
  pending :User[];
  trustedUproxy :User[];
  untrustedUproxy :User[];
}

export interface Contacts {
  getAccessContacts :ContactCategory;
  shareAccessContacts :ContactCategory;
}

 export interface UserCategories {
   getTab :string;
   shareTab :string;
 }

/**
 * Specific to one particular Social network.
 */
export interface Network {
  name :string;
  // TODO(salomegeo): Add more information about the user.
  userId :string;
  imageData ?:string;
  userName ?:string;
  logoutExpected: boolean;
  roster :{ [userId:string] :User };
}

export interface NotificationData {
  mode :string;
  network :string;
  user :string;
  unique ?:string;
}

/**
 * The User Interface class.
 *
 * Keeps persistent state between the popup opening and closing.
 * Manipulates the payloads received from UPDATES from the Core in preparation
 * for UI interaction.
 * Any COMMANDs from the UI should be directly called from the 'core' object.
 */
export class UserInterface implements ui_constants.UiApi {
  public view :ui_constants.View;
  public model = new Model();

  /* Instance management */
  // Instance you are getting access from. Null if you are not getting access.
  public instanceTryingToGetAccessFrom :string = null;
  private instanceGettingAccessFrom_ :string = null;
  // The instances you are giving access to.
  // Remote instances to add to this set are received in messages from Core.
  public instancesGivingAccessTo :{[instanceId :string] :boolean} = {};
  private mapInstanceIdToUser_ :{[instanceId :string] :User} = {};

  /* Getting and sharing */
  public gettingStatus :string = null;
  public sharingStatus :string = null;
  public unableToGet :boolean = false;
  public unableToShare :boolean = false;
  public isSharingDisabled :boolean = false;
  public proxyingId: string; // ID of the most recent failed proxying attempt.
  private userCancelledGetAttempt_ :boolean = false;

  /* Copypaste */
  /*
   * This is used to store the information for setting up a copy+paste
   * connection between establishing the connection and the user confirming
   * the start of proxying
   */
  public copyPasteState :CopyPasteState = new CopyPasteState();

  /* Translation */
  public i18n_t :Function = translator_module.i18n_t;
  public i18n_setLng :Function = translator_module.i18n_setLng;

  /* Constants */
  // Must be included in Chrome extension manifest's list of permissions.
  public AWS_FRONT_DOMAIN = 'https://a0.awsstatic.com/';

  /* About this uProxy installation */
  public portControlSupport = uproxy_core_api.PortControlSupport.PENDING;
  public browser :string = '';
  public availableVersion :string = null;

  // Changing this causes root.ts to fire a core-signal with the new value.
  public signalToFire :Object = null;

  public toastMessage :string = null;

  public showInviteControls: boolean = false;


  /**
   * UI must be constructed with hooks to Notifications and Core.
   * Upon construction, the UI installs update handlers on core.
   */
  constructor(
      public core   :CoreConnector,
      public browserApi :BrowserAPI) {
    this.updateView_();
    this.i18n_setLng(this.model.globalSettings.language);

    var firefoxMatches = navigator.userAgent.match(/Firefox\/(\d+)/);
    if (firefoxMatches) {
      if (parseInt(firefoxMatches[1], 10) === 37) {
        this.isSharingDisabled = true;
      }
    }

    core.on('core_connect', () => {
      this.updateView_();

      core.getFullState()
          .then(this.updateInitialState);
    });

    core.on('core_disconnect', () => {
      // When disconnected from the app, we should show the browser specific page
      // that shows the "app missing" message.
      this.view = ui_constants.View.BROWSER_ERROR;

      if (this.isGettingAccess()) {
        this.stoppedGetting({instanceId: null, error: true});
      }
    });

    // Add or update the online status of a network.
    core.onUpdate(uproxy_core_api.Update.NETWORK, this.syncNetwork_);

    // Attach handlers for USER updates.
    core.onUpdate(uproxy_core_api.Update.USER_SELF, this.syncUserSelf_);

    core.onUpdate(uproxy_core_api.Update.USER_FRIEND, this.syncUser);

    core.onUpdate(uproxy_core_api.Update.MANUAL_NETWORK_OUTBOUND_MESSAGE,
                  (message :social.PeerMessage) => {
      console.log('Manual network outbound message: ' +
                  JSON.stringify(message));
      // TODO: Display the message in the 'manual network' UI.
    });

    core.onUpdate(uproxy_core_api.Update.ONETIME_MESSAGE, (message:string) => {
      this.copyPasteState.message = message;
    });

    // indicates the current getting connection has ended
    core.onUpdate(uproxy_core_api.Update.STOP_GETTING, (error :boolean) => {
      this.copyPasteState.activeEndpoint = null;
      this.copyPasteState.active = false;
      this.stoppedGetting({instanceId: null, error: error});
    });

    // indicates we just started offering access through copy+paste
    core.onUpdate(uproxy_core_api.Update.START_GIVING, () => {
      if (!this.isGivingAccess()) {
        this.startGivingInUi();
      }
    });

    // indicates we just stopped offering access through copy+paste
    core.onUpdate(uproxy_core_api.Update.STOP_GIVING, () => {
      this.copyPasteState.localSharingWithRemote = social.SharingState.NONE;
      if (!this.isGivingAccess()) {
        this.stopGivingInUi();
      }
    });

    // status of the current copy+paste connection
    core.onUpdate(uproxy_core_api.Update.STATE, (state :uproxy_core_api.ConnectionState) => {
      this.copyPasteState.updateFromConnectionState(state);
    });

    core.onUpdate(uproxy_core_api.Update.STOP_GETTING_FROM_FRIEND,
        (data :social.StopProxyInfo) => { // TODO better type
        this.stoppedGetting(data);
    });

    core.onUpdate(uproxy_core_api.Update.START_GIVING_TO_FRIEND,
        (instanceId :string) => {
      // TODO (lucyhe): Update instancesGivingAccessTo before calling
      // startGivingInUi so that isGiving() is updated as early as possible.
      if (!this.isGivingAccess()) {
        this.startGivingInUi();
      }
      this.instancesGivingAccessTo[instanceId] = true;
      this.updateSharingStatusBar_();

      var user = this.mapInstanceIdToUser_[instanceId];
      user.isGettingFromMe = true;
      this.showNotification(this.i18n_t("STARTED_PROXYING",
          { name: user.name }), { mode: 'share', network: user.network.name, user: user.userId });
    });

    core.onUpdate(uproxy_core_api.Update.STOP_GIVING_TO_FRIEND,
        (instanceId :string) => {
      var isGettingFromMe = false;
      var user = this.mapInstanceIdToUser_[instanceId];

      // only show a notification if we knew we were prokying
      if (typeof this.instancesGivingAccessTo[instanceId] !== 'undefined') {
        this.showNotification(this.i18n_t("STOPPED_PROXYING",
          { name: user.name }), { mode: 'share', network: user.network.name, user: user.userId });
      }
      delete this.instancesGivingAccessTo[instanceId];
      if (!this.isGivingAccess()) {
        this.stopGivingInUi();
      }

      // Update user.isGettingFromMe
      for (var i = 0; i < user.allInstanceIds.length; ++i) {
        if (this.instancesGivingAccessTo[user.allInstanceIds[i]]) {
          isGettingFromMe = true;
          break;
        }
      }
      user.isGettingFromMe = isGettingFromMe;

      this.updateSharingStatusBar_();
    });

    core.onUpdate(uproxy_core_api.Update.FAILED_TO_GIVE,
        (info:uproxy_core_api.FailedToGetOrGive) => {
      console.error('proxying attempt ' + info.proxyingId + ' failed (giving)');

      this.toastMessage = this.i18n_t("UNABLE_TO_SHARE_WITH", {
        name: info.name
      });
      this.unableToShare = true;
      this.proxyingId = info.proxyingId;
    });

    core.onUpdate(uproxy_core_api.Update.FAILED_TO_GET,
        (info:uproxy_core_api.FailedToGetOrGive) => {
      if (this.userCancelledGetAttempt_) {
        console.error('proxying attempt ' + info.proxyingId +
            ' cancelled (getting)');
        this.userCancelledGetAttempt_ = false; // Reset.
      } else {
        console.error('proxying attempt ' + info.proxyingId +
            ' failed (getting)');
        if (!this.core.disconnectedWhileProxying) {
          // This is an immediate failure, i.e. failure of a connection attempt
          // that never connected.  It is not a retry.
          // Show the error toast indicating that a get attempt failed.
          this.toastMessage = this.i18n_t("UNABLE_TO_GET_FROM", {
            name: info.name
          });
          this.unableToGet = true;
        }
      }

      this.instanceTryingToGetAccessFrom = null;
      this.proxyingId = info.proxyingId;
      this.bringUproxyToFront();
    });

    core.onUpdate(
        uproxy_core_api.Update.POST_TO_CLOUDFRONT,
        (data :uproxy_core_api.CloudfrontPostData) => {
      this.postToCloudfrontSite(data.payload, data.cloudfrontPath);
    });

    core.onUpdate(uproxy_core_api.Update.CORE_UPDATE_AVAILABLE, this.coreUpdateAvailable_);

    core.onUpdate(uproxy_core_api.Update.PORT_CONTROL_STATUS,
                  this.setPortControlSupport_);

    browserApi.on('copyPasteUrlData', this.handleCopyPasteUrlData);
    browserApi.on('inviteUrlData', this.handleInviteUrlData);
    browserApi.on('notificationClicked', this.handleNotificationClick);
    browserApi.on('proxyDisconnected', this.proxyDisconnected);

    core.getFullState()
        .then(this.updateInitialState)
        .then(this.browserApi.handlePopupLaunch);
  }

  // Because of an observer (in root.ts) watching the value of
  // signalToFire, this function simulates firing a core-signal
  // from the background page.
  public fireSignal = (signalName :string, data ?:Object) => {
    this.signalToFire = {name: signalName, data: data};
  }

  private confirmationCallbacks_ :{[index :number] :Function} = {};
  // Don't use index 0 as it may be treated as false in confirmation code.
  private confirmationCallbackIndex_ = 1;
  public getConfirmation = (heading :string, text :string) => {
    return new Promise((F, R) => {
      var fulfillIndex = ++this.confirmationCallbackIndex_;
      var rejectIndex = ++this.confirmationCallbackIndex_;
      this.confirmationCallbacks_[fulfillIndex] = F;
      this.confirmationCallbacks_[rejectIndex] = R;
      this.fireSignal('open-dialog', {
        heading: heading,
        message: text,
        buttons: [{
          text: this.i18n_t("YES"),
          callbackIndex: fulfillIndex
        }, {
          text: this.i18n_t("NO"),
          callbackIndex: rejectIndex,
          dismissive: true
        }]
      });
    });
  }

  public invokeConfirmationCallback = (index :number, fulfill :boolean) => {
    if (index > this.confirmationCallbackIndex_) {
      console.error('Confirmation callback not found: ' + index);
      return;
    }
    this.confirmationCallbacks_[index]();
    delete this.confirmationCallbacks_[index];
    if (fulfill) {
      delete this.confirmationCallbacks_[index + 1];
    } else {
      delete this.confirmationCallbacks_[index - 1];
    }
  }

  public showNotification = (text :string, data ?:NotificationData) => {
    data = data ? data : { mode: '', network: '', user: '' };
    // non-uniqu but existing tags prevent the notification from displaying in some cases
    data.unique = Math.floor(Math.random() * 1E10).toString();

    try {
      var tag = JSON.stringify(data);
    } catch (e) {
      console.error('Could not encode data to tag');
      tag = data.unique;
    }

    this.browserApi.showNotification(text, tag);
  }

  public handleNotificationClick = (tag :string) => {
    // we want to bring uProxy to the front regardless of the info
    this.bringUproxyToFront();

    try {
      var data = JSON.parse(tag);

      if (data.network && data.user) {
        var network = this.model.getNetwork(data.network);
        if (network) {
          var contact = this.model.getUser(network, data.user);
        }
      }

      if (data.mode === 'get') {
        this.model.globalSettings.mode = ui_constants.Mode.GET;
        this.core.updateGlobalSettings(this.model.globalSettings);
        if (contact) {
          contact.getExpanded = true;
        }
      } else if (data.mode === 'share' && !this.isSharingDisabled) {
        this.model.globalSettings.mode = ui_constants.Mode.SHARE;
        this.core.updateGlobalSettings(this.model.globalSettings);
        if (contact) {
          contact.shareExpanded = true;
        }
      }
    } catch (e) {
      console.warn('error getting information from notification tag');
    }
  }

  private updateGettingStatusBar_ = () => {
    // TODO: localize this.
    if (this.instanceGettingAccessFrom_) {
      this.gettingStatus = this.i18n_t("GETTING_ACCESS_FROM", {
        name: this.mapInstanceIdToUser_[this.instanceGettingAccessFrom_].name
      });
    } else {
      this.gettingStatus = null;
    }
  }

  private updateSharingStatusBar_ = () => {
    // TODO: localize this - may require simpler formatting to work
    // in all languages.
    var instanceIds = Object.keys(this.instancesGivingAccessTo);
    if (instanceIds.length === 0) {
      this.sharingStatus = null;
    } else if (instanceIds.length === 1) {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_ONE", {
        name: this.mapInstanceIdToUser_[instanceIds[0]].name
      });
    } else if (instanceIds.length === 2) {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_TWO", {
        name1: this.mapInstanceIdToUser_[instanceIds[0]].name,
        name2: this.mapInstanceIdToUser_[instanceIds[1]].name
      });
    } else {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_MANY", {
        name: this.mapInstanceIdToUser_[instanceIds[0]].name,
        numOthers: (instanceIds.length - 1)
      });
    }
  }

  public parseUrlData = (url:string) : { type:social.PeerMessageType; message:string } => {
    var match = url.match(/https:\/\/www.uproxy.org\/(request|offer)\/(.*)/)
    if (!match) {
      throw new Error('invalid URL format');
    }
    return {
      type: match[1] === 'request' ?
          social.PeerMessageType.SIGNAL_FROM_CLIENT_PEER :
          social.PeerMessageType.SIGNAL_FROM_SERVER_PEER,
      message: decodeURIComponent(match[2])
    };
  }

  private addUserWithConfirmation_ = (url: string) : Promise<void> => {
    try {
      var token = url.substr(url.lastIndexOf('/') + 1);
      var tokenObj = JSON.parse(atob(token));
      var userName = tokenObj.userName;
      var networkName = tokenObj.networkName;
      var networkData = tokenObj.networkData;
      var userId = JSON.parse(networkData)['userId'];
    } catch(e) {
      return Promise.reject('Error parsing invite URL');
    }

    var confirmationMessage =
        this.i18n_t('ACCEPT_INVITE_CONFIRMATION', { name: userName });
    return this.getConfirmation('', confirmationMessage).then(() => {
      var socialNetworkInfo :social.SocialNetworkInfo = {
        name: networkName,
        userId: "" /* The current user's ID will be determined by the core. */
      };
      return this.core.acceptInvitation({network: socialNetworkInfo, data: userId});
    }).catch((e) => {
      // The user did not confirm adding their friend, not an error.
      return;
    })
  }

  public handleInviteUrlData = (url :string) => {
    var showUrlError = () => {
      this.fireSignal('open-dialog', {
        heading: '',
        message: this.i18n_t("INVITE_URL_ERROR"),
        buttons: [{
          text: this.i18n_t("OK")
        }]
      });
    };
    try {
      var token = url.substr(url.lastIndexOf('/') + 1);
      var tokenObj = JSON.parse(atob(token));
      var networkName = tokenObj.networkName;
      var userName = tokenObj.userName;
    } catch(e) {
      showUrlError();
      return;
    }
    if (!this.model.getNetwork(networkName)) {
      var confirmationTitle = this.i18n_t('LOGIN_REQUIRED_TITLE');
      var confirmationMessage =
          this.i18n_t('LOGIN_REQUIRED_MESSAGE',
          { network: this.getNetworkDisplayName(networkName), name: userName });
      this.getConfirmation(confirmationTitle, confirmationMessage).then(() => {
        this.login(networkName).then(() => {
          this.view = ui_constants.View.ROSTER;
          this.bringUproxyToFront();
          this.core.addUser(url).catch(showUrlError);
        });
      });
    } else {
      this.addUserWithConfirmation_(url).catch(showUrlError);;
    }
  }

  public handleCopyPasteUrlData = (url: string) => {
    console.log('received one-time URL from browser');

    if (this.model.onlineNetworks.length > 0) {
      console.log('Ignoring URL since we have an active network');
      this.copyPasteState.error = ui_constants.CopyPasteError.LOGGED_IN;
      return;
    }

    if (social.SharingState.NONE !== this.copyPasteState.localSharingWithRemote) {
      console.info('should not be processing a URL while in the middle of sharing');
      this.copyPasteState.error = ui_constants.CopyPasteError.UNEXPECTED;
      return;
    }

    // do not use the updateView function here, actual state may not have been
    // processed yet
    this.view = ui_constants.View.COPYPASTE;
    this.copyPasteState.error = ui_constants.CopyPasteError.NONE;

    try {
      var parsed = this.parseUrlData(url);

      // at this point, we assume everything is good, so let's check state
      switch (parsed.type) {
        case social.PeerMessageType.SIGNAL_FROM_CLIENT_PEER:
          this.core.startCopyPasteShare();
          break;
        case social.PeerMessageType.SIGNAL_FROM_SERVER_PEER:
          if (social.GettingState.TRYING_TO_GET_ACCESS
              !== this.copyPasteState.localGettingFromRemote) {
            console.warn('currently not expecting any information, aborting');
            this.copyPasteState.error = ui_constants.CopyPasteError.UNEXPECTED;
            return;
          }
          break;
      }

      console.log('sending one-time string to app');
      this.core.sendCopyPasteSignal(parsed.message);
    } catch (e) {
      console.error('invalid one-time URL: ' + e.message);
      this.copyPasteState.error = ui_constants.CopyPasteError.BAD_URL;
    }
  }

  public proxyDisconnected = () => {
    if (this.isGettingAccess()) {
      this.stopGettingFromInstance(this.instanceGettingAccessFrom_);
      this.fireSignal('open-proxy-error');
      this.bringUproxyToFront();
    }
  }

  /**
   * Takes all actions required when getting stops, including removing proxy
   * indicators from the UI, and retrying the connection if appropriate.
   * If user didn't end proxying, so if proxy session ended because of some
   * unexpected reason, user should be asked before reverting proxy settings.
   * if data.instanceId is null, it means to stop active proxying.
   */
  public stoppedGetting = (data :social.StopProxyInfo) => {
    var instanceId = data.instanceId || this.instanceGettingAccessFrom_;

    if (instanceId === this.instanceGettingAccessFrom_) {
      this.instanceGettingAccessFrom_ = null;
    }

    if (instanceId) {
      this.mapInstanceIdToUser_[instanceId].isSharingWithMe = false;
    }

    if (data.error) {
      if (instanceId) {
        // Auto-retry.
        this.core.disconnectedWhileProxying = instanceId;
        this.restartProxying();
      } else {
        // this handles the case where it was a one-time connection
        this.core.disconnectedWhileProxying = 'unknown';
      }

      // regardless, let the user know
      this.bringUproxyToFront();
    }

    this.updateGettingStatusBar_();
    this.updateIcon_();
  }

  /**
   * Undoes proxy configuration (e.g. chrome.proxy settings).
   */
  public stopUsingProxy = (userCancelled ?:boolean) => {
    if (userCancelled) {
      this.userCancelledGetAttempt_ = true;
    }

    this.browserApi.stopUsingProxy();
    this.core.disconnectedWhileProxying = null;
    this.updateIcon_();

    // revertProxySettings might call stopUsingProxy while a reconnection is
    // still being attempted.  In that case, we also want to terminate the
    // in-progress connection.
    if (this.instanceTryingToGetAccessFrom) {
      this.stopGettingFromInstance(this.instanceTryingToGetAccessFrom);
    }
  }

  private getInstancePath_ = (instanceId :string) => {
    var user = this.mapInstanceIdToUser_[instanceId];

    return <social.InstancePath>{
      network: {
        name: user.network.name,
        userId: user.network.userId
      },
      userId: user.userId,
      instanceId: instanceId
    };
  }

  public restartProxying = () => {
    this.startGettingFromInstance(this.core.disconnectedWhileProxying);
  }

  public startGettingFromInstance = (instanceId :string) :Promise<void> => {
    this.instanceTryingToGetAccessFrom = instanceId;

    return this.core.start(this.getInstancePath_(instanceId))
        .then((endpoint :net.Endpoint) => {
      this.instanceTryingToGetAccessFrom = null;
      // If we were getting access from some other instance
      // turn down the connection.
      if (this.instanceGettingAccessFrom_ &&
          this.instanceGettingAccessFrom_ != instanceId) {
        this.core.stop(this.getInstancePath_(this.instanceGettingAccessFrom_));
      }
      this.startGettingInUiAndConfig(instanceId, endpoint);
    }, (err:Error) => {
      this.instanceTryingToGetAccessFrom = null;
      throw err;
    });
  }

  public stopGettingFromInstance = (instanceId :string) :void => {
    if (instanceId === this.instanceTryingToGetAccessFrom) {
      // aborting pending connection
      this.instanceTryingToGetAccessFrom = null;
    } else if (instanceId === this.instanceGettingAccessFrom_) {
      // instance will be unset in eventual callback from core
    } else {
      // we have no idea what's going on
      console.error('Attempting to stop getting from unknown instance');
    }

    this.core.stop(this.getInstancePath_(instanceId));
  }

  public startGettingInUi = () => {
    this.updateIcon_(true);
  }

  /**
    * Sets extension icon to default and undoes proxy configuration.
    */
  public startGettingInUiAndConfig =
      (instanceId :string, endpoint :net.Endpoint) => {
    if (instanceId) {
      this.instanceGettingAccessFrom_ = instanceId;
      this.mapInstanceIdToUser_[instanceId].isSharingWithMe = true;
    }

    this.core.disconnectedWhileProxying = null;

    this.startGettingInUi();

    this.updateGettingStatusBar_();

    this.browserApi.startUsingProxy(endpoint);
  }

  /**
    * Set extension icon to the 'giving' icon.
    */
  public startGivingInUi = () => {
    this.updateIcon_(null, true);
  }

  private updateIcon_ = (isGetting?:boolean, isGiving?:boolean) => {
    if (isGetting === null || typeof isGetting === 'undefined') {
      isGetting = this.isGettingAccess();
    }

    if (isGiving === null || typeof isGiving === 'undefined') {
      isGiving = this.isGivingAccess();
    }

    if (this.core.disconnectedWhileProxying) {
      this.browserApi.setIcon(Constants.ERROR_ICON);
    } else if (isGetting && isGiving) {
      this.browserApi.setIcon(Constants.GETTING_SHARING_ICON);
    } else if (isGetting) {
      this.browserApi.setIcon(Constants.GETTING_ICON);
    } else if (isGiving) {
      this.browserApi.setIcon(Constants.SHARING_ICON);
    } else if (this.model.onlineNetworks.length > 0 ||
        !this.browserApi.hasInstalledThenLoggedIn) {
      this.browserApi.setIcon(Constants.DEFAULT_ICON);
      this.updateBadgeNotification_();
      return;
    } else {
      this.browserApi.setIcon(Constants.LOGGED_OUT_ICON);
    }

    // For all icons except the default icon, do not show notifications.
    this.browserApi.setBadgeNotification('');
  }

  /**
    * Set extension icon to the default icon.
    */
  public stopGivingInUi = () => {
    this.updateIcon_(null, false);
  }

  public isGettingAccess = () => {
    return this.instanceGettingAccessFrom_ != null;
  }

  public isGivingAccess = () => {
    return Object.keys(this.instancesGivingAccessTo).length > 0 ||
           this.copyPasteState.localSharingWithRemote === social.SharingState.SHARING_ACCESS;
  }

  /**
   * Synchronize a new network to be visible on this UI.
   */
  private syncNetwork_ = (networkMsg :social.NetworkMessage) => {
    var existingNetwork = this.model.getNetwork(networkMsg.name, networkMsg.userId);
    var displayName = this.getNetworkDisplayName(networkMsg.name);

    if (networkMsg.online) {
      if (!existingNetwork) {
        existingNetwork = {
          name: networkMsg.name,
          userId: networkMsg.userId,
          roster: {},
          logoutExpected: false,
          userName: networkMsg.userName,
          imageData: networkMsg.imageData
        };
        this.model.onlineNetworks.push(existingNetwork);
      }
    } else {
      if (existingNetwork) {
        this.model.removeNetwork(networkMsg.name, networkMsg.userId);

        if (!existingNetwork.logoutExpected &&
            this.supportsReconnect_(networkMsg.name) &&
            !this.core.disconnectedWhileProxying && !this.instanceGettingAccessFrom_) {
          console.warn('Unexpected logout, reconnecting to ' + networkMsg.name);
          this.reconnect(networkMsg.name);
        } else {
          if (this.instanceGettingAccessFrom_) {
            this.stopGettingFromInstance(this.instanceGettingAccessFrom_);
          }
          this.showNotification(
            this.i18n_t("LOGGED_OUT", { network: displayName }));

          if (!this.model.onlineNetworks.length) {
            this.view = ui_constants.View.SPLASH;
          }
        }
      }
    }

    this.updateView_();
    this.updateIcon_();
    this.updateShowInviteControls_();
  }

  private syncUserSelf_ = (payload :social.UserData) => {
    var network = this.model.getNetwork(payload.network);
    if (!network) {
      console.error('uproxy_core_api.Update.USER_SELF message for invalid network',
          payload.network);
      return;
    }
    var profile :social.UserProfileMessage = payload.user;
    network.userId = profile.userId;
    network.imageData = profile.imageData;
    network.userName = profile.name;
  }

  /**
   * Synchronize data about some friend.
   */
  public syncUser = (payload :social.UserData) => {
    var network = this.model.getNetwork(payload.network);
    if (!network) {
       return;
    }

    // Construct a UI-specific user object.
    var profile = payload.user;
    // Update / create if necessary a user, both in the network-specific
    // roster and the global roster.
    var user :User;
    user = this.model.getUser(network, profile.userId);
    var oldUserCategories :UserCategories = {
      getTab: null,
      shareTab: null
    };

    if (!user) {
      // New user.
      user = new User(profile.userId, network, this);
      network.roster[profile.userId] = user;
    } else {
      // Existing user, get the category before modifying any properties.
      oldUserCategories = user.getCategories();
    }

    user.update(payload);

    for (var i = 0; i < payload.allInstanceIds.length; ++i) {
      this.mapInstanceIdToUser_[payload.allInstanceIds[i]] = user;
    }

    for (var i = 0; i < payload.offeringInstances.length; i++) {
      var gettingState = payload.offeringInstances[i].localGettingFromRemote;
      var instanceId = payload.offeringInstances[i].instanceId;
      if (gettingState === social.GettingState.GETTING_ACCESS) {
        this.startGettingInUiAndConfig(instanceId, payload.offeringInstances[i].activeEndpoint);
        break;
      } else if (gettingState === social.GettingState.TRYING_TO_GET_ACCESS) {
        this.instanceTryingToGetAccessFrom = instanceId;
        this.updateGettingStatusBar_();
        break;
      }
    }

    for (var i = 0; i < payload.instancesSharingWithLocal.length; i++) {
      this.instancesGivingAccessTo[payload.instancesSharingWithLocal[i]] = true;
      user.isGettingFromMe = true;
    }

    var newUserCategories = user.getCategories();
    // Update the user's category in both get and share tabs.
    categorizeUser(user, this.model.contacts.getAccessContacts,
        oldUserCategories.getTab, newUserCategories.getTab);
    categorizeUser(user, this.model.contacts.shareAccessContacts,
        oldUserCategories.shareTab, newUserCategories.shareTab);
    this.updateBadgeNotification_();

    console.log('Synchronized user.', user);
  };

  public openTab = (url :string) => {
    this.browserApi.openTab(url);
  }

  public bringUproxyToFront = () => {
    this.browserApi.bringUproxyToFront();
  }

  public login = (network :string) : Promise<void> => {
    return this.core.login({ network : network, reconnect: false }).then(() => {
      this.browserApi.hasInstalledThenLoggedIn = true;
    }).catch((e :Error) => {
      this.showNotification(this.i18n_t("ERROR_SIGNING_IN", {network: network}));
      throw e;
    });
  }

  public logout = (networkInfo :social.SocialNetworkInfo) : Promise<void> => {
    var network = this.model.getNetwork(networkInfo.name);
    if (network) {
      // if we know about the network, record that we expect this logout to
      // happen
      network.logoutExpected = true;
    } else {
      console.warn('User is trying to log out of not-logged-in-network ' +
                   networkInfo.name);
    }

    return this.core.logout(networkInfo);
  }

  public reconnect = (network :string) => {
    this.model.reconnecting = true;
    var pingUrl = network == 'Facebook'
        ? 'https://graph.facebook.com' : 'https://www.googleapis.com';
    this.core.pingUntilOnline(pingUrl).then(() => {
      // Ensure that the user is still attempting to reconnect (i.e. they
      // haven't clicked to stop reconnecting while we were waiting for the
      // ping response).
      // TODO: this doesn't work quite right if the user is signed into multiple social networks
      if (this.model.reconnecting) {
        this.core.login({network: network, reconnect: true}).then(() => {
          // TODO: we don't necessarily want to hide the reconnect screen, as we might only be reconnecting to 1 of multiple disconnected networks
          this.stopReconnect();
        }).catch((e) => {
          // Reconnect failed, give up.
          // TODO: this may have only failed for 1 of multiple networks
          this.stopReconnect();
          this.showNotification(
              this.i18n_t("LOGGED_OUT", { network: network }));

          this.updateView_();
        });
      }
    });
  }

  public stopReconnect = () => {
    this.model.reconnecting = false;
  }

  private cloudfrontDomains_ = [
    "d1wtwocg4wx1ih.cloudfront.net"
  ]

  public postToCloudfrontSite = (payload :Object, cloudfrontPath :string,
                                 maxAttempts ?:number)
      : Promise<void> => {
    console.log('postToCloudfrontSite: ', payload, cloudfrontPath);
    if (!maxAttempts || maxAttempts > this.cloudfrontDomains_.length) {
      // default to trying every possible URL
      maxAttempts = this.cloudfrontDomains_.length;
    }
    var attempts = 0;
    var doAttempts = (error ?:Error) : Promise<void> => {
      if (attempts < maxAttempts) {
        // we want to keep trying this until we either run out of urls to
        // send to or one of the requests succeeds.  We set this up by
        // creating a lambda to call the post with failures set up to recurse
        return this.browserApi.frontedPost(payload, this.AWS_FRONT_DOMAIN,
          this.cloudfrontDomains_[attempts++], cloudfrontPath
        ).catch(doAttempts);
      }
      throw error;
    }
    return doAttempts();
  }

  public sendFeedback =
      (feedback :uproxy_core_api.UserFeedback) : Promise<void> => {
    var logsPromise :Promise<string>;
    if (feedback.logs) {
      logsPromise = this.core.getLogs().then((logs) => {
        var browserInfo = 'Browser Info: ' + feedback.browserInfo + '\n\n';
        return browserInfo + logs;
      });
    } else {
      logsPromise = Promise.resolve('');
    }
    return logsPromise.then((logs) => {
      var payload = {
        email: feedback.email,
        feedback: feedback.feedback,
        logs: logs,
        feedbackType: uproxy_core_api.UserFeedbackType[feedback.feedbackType],
        proxyingId: this.proxyingId
      };

      return this.postToCloudfrontSite(payload, 'submit-feedback');
    });
  }

  public setMode = (mode :ui_constants.Mode) => {
    this.model.globalSettings.mode = mode;
    this.core.updateGlobalSettings(this.model.globalSettings);
  }

  public updateLanguage = (newLanguage :string) => {
    this.model.globalSettings.language = newLanguage;
    this.core.updateGlobalSettings(this.model.globalSettings);
    this.i18n_setLng(newLanguage);
  }

  public updateInitialState = (state :uproxy_core_api.InitialState) => {
    console.log('Received uproxy_core_api.Update.INITIAL_STATE:', state);
    this.model.networkNames = state.networkNames;
    this.availableVersion = state.availableVersion;
    if (state.globalSettings.language !== this.model.globalSettings.language) {
      this.i18n_setLng(state.globalSettings.language);
    }
    this.model.updateGlobalSettings(state.globalSettings);

    // Maybe refactor this to be copyPasteState.
    this.copyPasteState.updateFromConnectionState(state.copyPasteConnection);

    while (this.model.onlineNetworks.length > 0) {<commit_msg>Fix bug where the language would always be English (instead of what's saved in storage) if you reload the extension.<commit_after>/// <reference path='../../../../third_party/typings/i18next/i18next.d.ts' />

/**
 * ui.ts
 *
 * Common User Interface state holder and changer.
 */

import ui_constants = require('../../interfaces/ui');
import CopyPasteState = require('./copypaste-state');
import CoreConnector = require('./core_connector');
import uproxy_core_api = require('../../interfaces/uproxy_core_api');
import browser_api = require('../../interfaces/browser_api');
import BrowserAPI = browser_api.BrowserAPI;
import net = require('../../../../third_party/uproxy-lib/net/net.types');
import noreConnector = require('./core_connector');
import user_module = require('./user');
import User = user_module.User;
import social = require('../../interfaces/social');
import Constants = require('./constants');
import translator_module = require('./translator');
import _ = require('lodash');
import network_options = require('../../generic/network-options');

var NETWORK_OPTIONS = network_options.NETWORK_OPTIONS;

// Filenames for icons.
// Two important things about using these strings:
// 1) When updating the icon strings below, default values in the Chrome
// manifests and Firefox main.js should also be changed to match.
// 2) These are only the suffixes of the icon names. Because we have
// different sizes of icons, the actual filenames have the dimension
// as a prefix. E.g. "19_online.gif" for the 19x19 pixel version.

export class Model {
  public networkNames :string[] = [];

  public onlineNetworks :Network[] = [];

  public contacts :Contacts = {
    getAccessContacts: {
      pending: [],
      trustedUproxy: [],
      untrustedUproxy: [],
    },
    shareAccessContacts: {
      pending: [],
      trustedUproxy: [],
      untrustedUproxy: [],
    }
  };

  public globalSettings :uproxy_core_api.GlobalSettings = {
    version: 0,
    description: '',
    stunServers: [],
    hasSeenSharingEnabledScreen: false,
    hasSeenWelcome: false,
    splashState : 0,
    mode : ui_constants.Mode.GET,
    allowNonUnicast: false,
    statsReportingEnabled: false,
    consoleFilter: 2, // loggingTypes.Level.warn
    language: 'en',
    force_message_version: 0,
    hasSeenGoogleAndFacebookChangedNotification: false
  };

  public reconnecting = false;

  // userId is included as an optional parameter because we will eventually
  // want to use it to get an accurate network.  For now, it is ignored and
  // serves to remind us of where we still need to add the info
  public getNetwork = (networkName :string, userId?:string) :Network => {
    return _.find(this.onlineNetworks, { name: networkName });
  }

  public removeNetwork = (networkName :string, userId :string) => {
    var network = this.getNetwork(networkName, userId);

    for (var otherUserId in network.roster) {
      var user = this.getUser(network, otherUserId);
      var userCategories = user.getCategories();
      categorizeUser(user, this.contacts.getAccessContacts,
                     userCategories.getTab, null);
      categorizeUser(user, this.contacts.shareAccessContacts,
                     userCategories.shareTab, null);
    }

    _.remove(this.onlineNetworks, { name: networkName });
  }

  public getUser = (network :Network, userId :string) :User => {
    if (network.roster[userId]) {
      return network.roster[userId];
    }

    return null;
  }

  public updateGlobalSettings = (settings :Object) => {
    _.merge(this.globalSettings, settings, (a :any, b :any) => {
      if (_.isArray(a) && _.isArray(b)) {
        return b;
      }

      return undefined;
    });
  }
}

export interface ContactCategory {
  [type :string] :User[];
  pending :User[];
  trustedUproxy :User[];
  untrustedUproxy :User[];
}

export interface Contacts {
  getAccessContacts :ContactCategory;
  shareAccessContacts :ContactCategory;
}

 export interface UserCategories {
   getTab :string;
   shareTab :string;
 }

/**
 * Specific to one particular Social network.
 */
export interface Network {
  name :string;
  // TODO(salomegeo): Add more information about the user.
  userId :string;
  imageData ?:string;
  userName ?:string;
  logoutExpected: boolean;
  roster :{ [userId:string] :User };
}

export interface NotificationData {
  mode :string;
  network :string;
  user :string;
  unique ?:string;
}

/**
 * The User Interface class.
 *
 * Keeps persistent state between the popup opening and closing.
 * Manipulates the payloads received from UPDATES from the Core in preparation
 * for UI interaction.
 * Any COMMANDs from the UI should be directly called from the 'core' object.
 */
export class UserInterface implements ui_constants.UiApi {
  public view :ui_constants.View;
  public model = new Model();

  /* Instance management */
  // Instance you are getting access from. Null if you are not getting access.
  public instanceTryingToGetAccessFrom :string = null;
  private instanceGettingAccessFrom_ :string = null;
  // The instances you are giving access to.
  // Remote instances to add to this set are received in messages from Core.
  public instancesGivingAccessTo :{[instanceId :string] :boolean} = {};
  private mapInstanceIdToUser_ :{[instanceId :string] :User} = {};

  /* Getting and sharing */
  public gettingStatus :string = null;
  public sharingStatus :string = null;
  public unableToGet :boolean = false;
  public unableToShare :boolean = false;
  public isSharingDisabled :boolean = false;
  public proxyingId: string; // ID of the most recent failed proxying attempt.
  private userCancelledGetAttempt_ :boolean = false;

  /* Copypaste */
  /*
   * This is used to store the information for setting up a copy+paste
   * connection between establishing the connection and the user confirming
   * the start of proxying
   */
  public copyPasteState :CopyPasteState = new CopyPasteState();

  /* Translation */
  public i18n_t :Function = translator_module.i18n_t;
  public i18n_setLng :Function = translator_module.i18n_setLng;

  /* Constants */
  // Must be included in Chrome extension manifest's list of permissions.
  public AWS_FRONT_DOMAIN = 'https://a0.awsstatic.com/';

  /* About this uProxy installation */
  public portControlSupport = uproxy_core_api.PortControlSupport.PENDING;
  public browser :string = '';
  public availableVersion :string = null;

  // Changing this causes root.ts to fire a core-signal with the new value.
  public signalToFire :Object = null;

  public toastMessage :string = null;

  public showInviteControls: boolean = false;


  /**
   * UI must be constructed with hooks to Notifications and Core.
   * Upon construction, the UI installs update handlers on core.
   */
  constructor(
      public core   :CoreConnector,
      public browserApi :BrowserAPI) {
    this.updateView_();

    var firefoxMatches = navigator.userAgent.match(/Firefox\/(\d+)/);
    if (firefoxMatches) {
      if (parseInt(firefoxMatches[1], 10) === 37) {
        this.isSharingDisabled = true;
      }
    }

    core.on('core_connect', () => {
      this.updateView_();

      core.getFullState()
          .then(this.updateInitialState);
    });

    core.on('core_disconnect', () => {
      // When disconnected from the app, we should show the browser specific page
      // that shows the "app missing" message.
      this.view = ui_constants.View.BROWSER_ERROR;

      if (this.isGettingAccess()) {
        this.stoppedGetting({instanceId: null, error: true});
      }
    });

    // Add or update the online status of a network.
    core.onUpdate(uproxy_core_api.Update.NETWORK, this.syncNetwork_);

    // Attach handlers for USER updates.
    core.onUpdate(uproxy_core_api.Update.USER_SELF, this.syncUserSelf_);

    core.onUpdate(uproxy_core_api.Update.USER_FRIEND, this.syncUser);

    core.onUpdate(uproxy_core_api.Update.MANUAL_NETWORK_OUTBOUND_MESSAGE,
                  (message :social.PeerMessage) => {
      console.log('Manual network outbound message: ' +
                  JSON.stringify(message));
      // TODO: Display the message in the 'manual network' UI.
    });

    core.onUpdate(uproxy_core_api.Update.ONETIME_MESSAGE, (message:string) => {
      this.copyPasteState.message = message;
    });

    // indicates the current getting connection has ended
    core.onUpdate(uproxy_core_api.Update.STOP_GETTING, (error :boolean) => {
      this.copyPasteState.activeEndpoint = null;
      this.copyPasteState.active = false;
      this.stoppedGetting({instanceId: null, error: error});
    });

    // indicates we just started offering access through copy+paste
    core.onUpdate(uproxy_core_api.Update.START_GIVING, () => {
      if (!this.isGivingAccess()) {
        this.startGivingInUi();
      }
    });

    // indicates we just stopped offering access through copy+paste
    core.onUpdate(uproxy_core_api.Update.STOP_GIVING, () => {
      this.copyPasteState.localSharingWithRemote = social.SharingState.NONE;
      if (!this.isGivingAccess()) {
        this.stopGivingInUi();
      }
    });

    // status of the current copy+paste connection
    core.onUpdate(uproxy_core_api.Update.STATE, (state :uproxy_core_api.ConnectionState) => {
      this.copyPasteState.updateFromConnectionState(state);
    });

    core.onUpdate(uproxy_core_api.Update.STOP_GETTING_FROM_FRIEND,
        (data :social.StopProxyInfo) => { // TODO better type
        this.stoppedGetting(data);
    });

    core.onUpdate(uproxy_core_api.Update.START_GIVING_TO_FRIEND,
        (instanceId :string) => {
      // TODO (lucyhe): Update instancesGivingAccessTo before calling
      // startGivingInUi so that isGiving() is updated as early as possible.
      if (!this.isGivingAccess()) {
        this.startGivingInUi();
      }
      this.instancesGivingAccessTo[instanceId] = true;
      this.updateSharingStatusBar_();

      var user = this.mapInstanceIdToUser_[instanceId];
      user.isGettingFromMe = true;
      this.showNotification(this.i18n_t("STARTED_PROXYING",
          { name: user.name }), { mode: 'share', network: user.network.name, user: user.userId });
    });

    core.onUpdate(uproxy_core_api.Update.STOP_GIVING_TO_FRIEND,
        (instanceId :string) => {
      var isGettingFromMe = false;
      var user = this.mapInstanceIdToUser_[instanceId];

      // only show a notification if we knew we were prokying
      if (typeof this.instancesGivingAccessTo[instanceId] !== 'undefined') {
        this.showNotification(this.i18n_t("STOPPED_PROXYING",
          { name: user.name }), { mode: 'share', network: user.network.name, user: user.userId });
      }
      delete this.instancesGivingAccessTo[instanceId];
      if (!this.isGivingAccess()) {
        this.stopGivingInUi();
      }

      // Update user.isGettingFromMe
      for (var i = 0; i < user.allInstanceIds.length; ++i) {
        if (this.instancesGivingAccessTo[user.allInstanceIds[i]]) {
          isGettingFromMe = true;
          break;
        }
      }
      user.isGettingFromMe = isGettingFromMe;

      this.updateSharingStatusBar_();
    });

    core.onUpdate(uproxy_core_api.Update.FAILED_TO_GIVE,
        (info:uproxy_core_api.FailedToGetOrGive) => {
      console.error('proxying attempt ' + info.proxyingId + ' failed (giving)');

      this.toastMessage = this.i18n_t("UNABLE_TO_SHARE_WITH", {
        name: info.name
      });
      this.unableToShare = true;
      this.proxyingId = info.proxyingId;
    });

    core.onUpdate(uproxy_core_api.Update.FAILED_TO_GET,
        (info:uproxy_core_api.FailedToGetOrGive) => {
      if (this.userCancelledGetAttempt_) {
        console.error('proxying attempt ' + info.proxyingId +
            ' cancelled (getting)');
        this.userCancelledGetAttempt_ = false; // Reset.
      } else {
        console.error('proxying attempt ' + info.proxyingId +
            ' failed (getting)');
        if (!this.core.disconnectedWhileProxying) {
          // This is an immediate failure, i.e. failure of a connection attempt
          // that never connected.  It is not a retry.
          // Show the error toast indicating that a get attempt failed.
          this.toastMessage = this.i18n_t("UNABLE_TO_GET_FROM", {
            name: info.name
          });
          this.unableToGet = true;
        }
      }

      this.instanceTryingToGetAccessFrom = null;
      this.proxyingId = info.proxyingId;
      this.bringUproxyToFront();
    });

    core.onUpdate(
        uproxy_core_api.Update.POST_TO_CLOUDFRONT,
        (data :uproxy_core_api.CloudfrontPostData) => {
      this.postToCloudfrontSite(data.payload, data.cloudfrontPath);
    });

    core.onUpdate(uproxy_core_api.Update.CORE_UPDATE_AVAILABLE, this.coreUpdateAvailable_);

    core.onUpdate(uproxy_core_api.Update.PORT_CONTROL_STATUS,
                  this.setPortControlSupport_);

    browserApi.on('copyPasteUrlData', this.handleCopyPasteUrlData);
    browserApi.on('inviteUrlData', this.handleInviteUrlData);
    browserApi.on('notificationClicked', this.handleNotificationClick);
    browserApi.on('proxyDisconnected', this.proxyDisconnected);

    core.getFullState()
        .then(this.updateInitialState)
        .then(this.browserApi.handlePopupLaunch);
  }

  // Because of an observer (in root.ts) watching the value of
  // signalToFire, this function simulates firing a core-signal
  // from the background page.
  public fireSignal = (signalName :string, data ?:Object) => {
    this.signalToFire = {name: signalName, data: data};
  }

  private confirmationCallbacks_ :{[index :number] :Function} = {};
  // Don't use index 0 as it may be treated as false in confirmation code.
  private confirmationCallbackIndex_ = 1;
  public getConfirmation = (heading :string, text :string) => {
    return new Promise((F, R) => {
      var fulfillIndex = ++this.confirmationCallbackIndex_;
      var rejectIndex = ++this.confirmationCallbackIndex_;
      this.confirmationCallbacks_[fulfillIndex] = F;
      this.confirmationCallbacks_[rejectIndex] = R;
      this.fireSignal('open-dialog', {
        heading: heading,
        message: text,
        buttons: [{
          text: this.i18n_t("YES"),
          callbackIndex: fulfillIndex
        }, {
          text: this.i18n_t("NO"),
          callbackIndex: rejectIndex,
          dismissive: true
        }]
      });
    });
  }

  public invokeConfirmationCallback = (index :number, fulfill :boolean) => {
    if (index > this.confirmationCallbackIndex_) {
      console.error('Confirmation callback not found: ' + index);
      return;
    }
    this.confirmationCallbacks_[index]();
    delete this.confirmationCallbacks_[index];
    if (fulfill) {
      delete this.confirmationCallbacks_[index + 1];
    } else {
      delete this.confirmationCallbacks_[index - 1];
    }
  }

  public showNotification = (text :string, data ?:NotificationData) => {
    data = data ? data : { mode: '', network: '', user: '' };
    // non-uniqu but existing tags prevent the notification from displaying in some cases
    data.unique = Math.floor(Math.random() * 1E10).toString();

    try {
      var tag = JSON.stringify(data);
    } catch (e) {
      console.error('Could not encode data to tag');
      tag = data.unique;
    }

    this.browserApi.showNotification(text, tag);
  }

  public handleNotificationClick = (tag :string) => {
    // we want to bring uProxy to the front regardless of the info
    this.bringUproxyToFront();

    try {
      var data = JSON.parse(tag);

      if (data.network && data.user) {
        var network = this.model.getNetwork(data.network);
        if (network) {
          var contact = this.model.getUser(network, data.user);
        }
      }

      if (data.mode === 'get') {
        this.model.globalSettings.mode = ui_constants.Mode.GET;
        this.core.updateGlobalSettings(this.model.globalSettings);
        if (contact) {
          contact.getExpanded = true;
        }
      } else if (data.mode === 'share' && !this.isSharingDisabled) {
        this.model.globalSettings.mode = ui_constants.Mode.SHARE;
        this.core.updateGlobalSettings(this.model.globalSettings);
        if (contact) {
          contact.shareExpanded = true;
        }
      }
    } catch (e) {
      console.warn('error getting information from notification tag');
    }
  }

  private updateGettingStatusBar_ = () => {
    // TODO: localize this.
    if (this.instanceGettingAccessFrom_) {
      this.gettingStatus = this.i18n_t("GETTING_ACCESS_FROM", {
        name: this.mapInstanceIdToUser_[this.instanceGettingAccessFrom_].name
      });
    } else {
      this.gettingStatus = null;
    }
  }

  private updateSharingStatusBar_ = () => {
    // TODO: localize this - may require simpler formatting to work
    // in all languages.
    var instanceIds = Object.keys(this.instancesGivingAccessTo);
    if (instanceIds.length === 0) {
      this.sharingStatus = null;
    } else if (instanceIds.length === 1) {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_ONE", {
        name: this.mapInstanceIdToUser_[instanceIds[0]].name
      });
    } else if (instanceIds.length === 2) {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_TWO", {
        name1: this.mapInstanceIdToUser_[instanceIds[0]].name,
        name2: this.mapInstanceIdToUser_[instanceIds[1]].name
      });
    } else {
      this.sharingStatus = this.i18n_t("SHARING_ACCESS_WITH_MANY", {
        name: this.mapInstanceIdToUser_[instanceIds[0]].name,
        numOthers: (instanceIds.length - 1)
      });
    }
  }

  public parseUrlData = (url:string) : { type:social.PeerMessageType; message:string } => {
    var match = url.match(/https:\/\/www.uproxy.org\/(request|offer)\/(.*)/)
    if (!match) {
      throw new Error('invalid URL format');
    }
    return {
      type: match[1] === 'request' ?
          social.PeerMessageType.SIGNAL_FROM_CLIENT_PEER :
          social.PeerMessageType.SIGNAL_FROM_SERVER_PEER,
      message: decodeURIComponent(match[2])
    };
  }

  private addUserWithConfirmation_ = (url: string) : Promise<void> => {
    try {
      var token = url.substr(url.lastIndexOf('/') + 1);
      var tokenObj = JSON.parse(atob(token));
      var userName = tokenObj.userName;
      var networkName = tokenObj.networkName;
      var networkData = tokenObj.networkData;
      var userId = JSON.parse(networkData)['userId'];
    } catch(e) {
      return Promise.reject('Error parsing invite URL');
    }

    var confirmationMessage =
        this.i18n_t('ACCEPT_INVITE_CONFIRMATION', { name: userName });
    return this.getConfirmation('', confirmationMessage).then(() => {
      var socialNetworkInfo :social.SocialNetworkInfo = {
        name: networkName,
        userId: "" /* The current user's ID will be determined by the core. */
      };
      return this.core.acceptInvitation({network: socialNetworkInfo, data: userId});
    }).catch((e) => {
      // The user did not confirm adding their friend, not an error.
      return;
    })
  }

  public handleInviteUrlData = (url :string) => {
    var showUrlError = () => {
      this.fireSignal('open-dialog', {
        heading: '',
        message: this.i18n_t("INVITE_URL_ERROR"),
        buttons: [{
          text: this.i18n_t("OK")
        }]
      });
    };
    try {
      var token = url.substr(url.lastIndexOf('/') + 1);
      var tokenObj = JSON.parse(atob(token));
      var networkName = tokenObj.networkName;
      var userName = tokenObj.userName;
    } catch(e) {
      showUrlError();
      return;
    }
    if (!this.model.getNetwork(networkName)) {
      var confirmationTitle = this.i18n_t('LOGIN_REQUIRED_TITLE');
      var confirmationMessage =
          this.i18n_t('LOGIN_REQUIRED_MESSAGE',
          { network: this.getNetworkDisplayName(networkName), name: userName });
      this.getConfirmation(confirmationTitle, confirmationMessage).then(() => {
        this.login(networkName).then(() => {
          this.view = ui_constants.View.ROSTER;
          this.bringUproxyToFront();
          this.core.addUser(url).catch(showUrlError);
        });
      });
    } else {
      this.addUserWithConfirmation_(url).catch(showUrlError);;
    }
  }

  public handleCopyPasteUrlData = (url: string) => {
    console.log('received one-time URL from browser');

    if (this.model.onlineNetworks.length > 0) {
      console.log('Ignoring URL since we have an active network');
      this.copyPasteState.error = ui_constants.CopyPasteError.LOGGED_IN;
      return;
    }

    if (social.SharingState.NONE !== this.copyPasteState.localSharingWithRemote) {
      console.info('should not be processing a URL while in the middle of sharing');
      this.copyPasteState.error = ui_constants.CopyPasteError.UNEXPECTED;
      return;
    }

    // do not use the updateView function here, actual state may not have been
    // processed yet
    this.view = ui_constants.View.COPYPASTE;
    this.copyPasteState.error = ui_constants.CopyPasteError.NONE;

    try {
      var parsed = this.parseUrlData(url);

      // at this point, we assume everything is good, so let's check state
      switch (parsed.type) {
        case social.PeerMessageType.SIGNAL_FROM_CLIENT_PEER:
          this.core.startCopyPasteShare();
          break;
        case social.PeerMessageType.SIGNAL_FROM_SERVER_PEER:
          if (social.GettingState.TRYING_TO_GET_ACCESS
              !== this.copyPasteState.localGettingFromRemote) {
            console.warn('currently not expecting any information, aborting');
            this.copyPasteState.error = ui_constants.CopyPasteError.UNEXPECTED;
            return;
          }
          break;
      }

      console.log('sending one-time string to app');
      this.core.sendCopyPasteSignal(parsed.message);
    } catch (e) {
      console.error('invalid one-time URL: ' + e.message);
      this.copyPasteState.error = ui_constants.CopyPasteError.BAD_URL;
    }
  }

  public proxyDisconnected = () => {
    if (this.isGettingAccess()) {
      this.stopGettingFromInstance(this.instanceGettingAccessFrom_);
      this.fireSignal('open-proxy-error');
      this.bringUproxyToFront();
    }
  }

  /**
   * Takes all actions required when getting stops, including removing proxy
   * indicators from the UI, and retrying the connection if appropriate.
   * If user didn't end proxying, so if proxy session ended because of some
   * unexpected reason, user should be asked before reverting proxy settings.
   * if data.instanceId is null, it means to stop active proxying.
   */
  public stoppedGetting = (data :social.StopProxyInfo) => {
    var instanceId = data.instanceId || this.instanceGettingAccessFrom_;

    if (instanceId === this.instanceGettingAccessFrom_) {
      this.instanceGettingAccessFrom_ = null;
    }

    if (instanceId) {
      this.mapInstanceIdToUser_[instanceId].isSharingWithMe = false;
    }

    if (data.error) {
      if (instanceId) {
        // Auto-retry.
        this.core.disconnectedWhileProxying = instanceId;
        this.restartProxying();
      } else {
        // this handles the case where it was a one-time connection
        this.core.disconnectedWhileProxying = 'unknown';
      }

      // regardless, let the user know
      this.bringUproxyToFront();
    }

    this.updateGettingStatusBar_();
    this.updateIcon_();
  }

  /**
   * Undoes proxy configuration (e.g. chrome.proxy settings).
   */
  public stopUsingProxy = (userCancelled ?:boolean) => {
    if (userCancelled) {
      this.userCancelledGetAttempt_ = true;
    }

    this.browserApi.stopUsingProxy();
    this.core.disconnectedWhileProxying = null;
    this.updateIcon_();

    // revertProxySettings might call stopUsingProxy while a reconnection is
    // still being attempted.  In that case, we also want to terminate the
    // in-progress connection.
    if (this.instanceTryingToGetAccessFrom) {
      this.stopGettingFromInstance(this.instanceTryingToGetAccessFrom);
    }
  }

  private getInstancePath_ = (instanceId :string) => {
    var user = this.mapInstanceIdToUser_[instanceId];

    return <social.InstancePath>{
      network: {
        name: user.network.name,
        userId: user.network.userId
      },
      userId: user.userId,
      instanceId: instanceId
    };
  }

  public restartProxying = () => {
    this.startGettingFromInstance(this.core.disconnectedWhileProxying);
  }

  public startGettingFromInstance = (instanceId :string) :Promise<void> => {
    this.instanceTryingToGetAccessFrom = instanceId;

    return this.core.start(this.getInstancePath_(instanceId))
        .then((endpoint :net.Endpoint) => {
      this.instanceTryingToGetAccessFrom = null;
      // If we were getting access from some other instance
      // turn down the connection.
      if (this.instanceGettingAccessFrom_ &&
          this.instanceGettingAccessFrom_ != instanceId) {
        this.core.stop(this.getInstancePath_(this.instanceGettingAccessFrom_));
      }
      this.startGettingInUiAndConfig(instanceId, endpoint);
    }, (err:Error) => {
      this.instanceTryingToGetAccessFrom = null;
      throw err;
    });
  }

  public stopGettingFromInstance = (instanceId :string) :void => {
    if (instanceId === this.instanceTryingToGetAccessFrom) {
      // aborting pending connection
      this.instanceTryingToGetAccessFrom = null;
    } else if (instanceId === this.instanceGettingAccessFrom_) {
      // instance will be unset in eventual callback from core
    } else {
      // we have no idea what's going on
      console.error('Attempting to stop getting from unknown instance');
    }

    this.core.stop(this.getInstancePath_(instanceId));
  }

  public startGettingInUi = () => {
    this.updateIcon_(true);
  }

  /**
    * Sets extension icon to default and undoes proxy configuration.
    */
  public startGettingInUiAndConfig =
      (instanceId :string, endpoint :net.Endpoint) => {
    if (instanceId) {
      this.instanceGettingAccessFrom_ = instanceId;
      this.mapInstanceIdToUser_[instanceId].isSharingWithMe = true;
    }

    this.core.disconnectedWhileProxying = null;

    this.startGettingInUi();

    this.updateGettingStatusBar_();

    this.browserApi.startUsingProxy(endpoint);
  }

  /**
    * Set extension icon to the 'giving' icon.
    */
  public startGivingInUi = () => {
    this.updateIcon_(null, true);
  }

  private updateIcon_ = (isGetting?:boolean, isGiving?:boolean) => {
    if (isGetting === null || typeof isGetting === 'undefined') {
      isGetting = this.isGettingAccess();
    }

    if (isGiving === null || typeof isGiving === 'undefined') {
      isGiving = this.isGivingAccess();
    }

    if (this.core.disconnectedWhileProxying) {
      this.browserApi.setIcon(Constants.ERROR_ICON);
    } else if (isGetting && isGiving) {
      this.browserApi.setIcon(Constants.GETTING_SHARING_ICON);
    } else if (isGetting) {
      this.browserApi.setIcon(Constants.GETTING_ICON);
    } else if (isGiving) {
      this.browserApi.setIcon(Constants.SHARING_ICON);
    } else if (this.model.onlineNetworks.length > 0 ||
        !this.browserApi.hasInstalledThenLoggedIn) {
      this.browserApi.setIcon(Constants.DEFAULT_ICON);
      this.updateBadgeNotification_();
      return;
    } else {
      this.browserApi.setIcon(Constants.LOGGED_OUT_ICON);
    }

    // For all icons except the default icon, do not show notifications.
    this.browserApi.setBadgeNotification('');
  }

  /**
    * Set extension icon to the default icon.
    */
  public stopGivingInUi = () => {
    this.updateIcon_(null, false);
  }

  public isGettingAccess = () => {
    return this.instanceGettingAccessFrom_ != null;
  }

  public isGivingAccess = () => {
    return Object.keys(this.instancesGivingAccessTo).length > 0 ||
           this.copyPasteState.localSharingWithRemote === social.SharingState.SHARING_ACCESS;
  }

  /**
   * Synchronize a new network to be visible on this UI.
   */
  private syncNetwork_ = (networkMsg :social.NetworkMessage) => {
    var existingNetwork = this.model.getNetwork(networkMsg.name, networkMsg.userId);
    var displayName = this.getNetworkDisplayName(networkMsg.name);

    if (networkMsg.online) {
      if (!existingNetwork) {
        existingNetwork = {
          name: networkMsg.name,
          userId: networkMsg.userId,
          roster: {},
          logoutExpected: false,
          userName: networkMsg.userName,
          imageData: networkMsg.imageData
        };
        this.model.onlineNetworks.push(existingNetwork);
      }
    } else {
      if (existingNetwork) {
        this.model.removeNetwork(networkMsg.name, networkMsg.userId);

        if (!existingNetwork.logoutExpected &&
            this.supportsReconnect_(networkMsg.name) &&
            !this.core.disconnectedWhileProxying && !this.instanceGettingAccessFrom_) {
          console.warn('Unexpected logout, reconnecting to ' + networkMsg.name);
          this.reconnect(networkMsg.name);
        } else {
          if (this.instanceGettingAccessFrom_) {
            this.stopGettingFromInstance(this.instanceGettingAccessFrom_);
          }
          this.showNotification(
            this.i18n_t("LOGGED_OUT", { network: displayName }));

          if (!this.model.onlineNetworks.length) {
            this.view = ui_constants.View.SPLASH;
          }
        }
      }
    }

    this.updateView_();
    this.updateIcon_();
    this.updateShowInviteControls_();
  }

  private syncUserSelf_ = (payload :social.UserData) => {
    var network = this.model.getNetwork(payload.network);
    if (!network) {
      console.error('uproxy_core_api.Update.USER_SELF message for invalid network',
          payload.network);
      return;
    }
    var profile :social.UserProfileMessage = payload.user;
    network.userId = profile.userId;
    network.imageData = profile.imageData;
    network.userName = profile.name;
  }

  /**
   * Synchronize data about some friend.
   */
  public syncUser = (payload :social.UserData) => {
    var network = this.model.getNetwork(payload.network);
    if (!network) {
       return;
    }

    // Construct a UI-specific user object.
    var profile = payload.user;
    // Update / create if necessary a user, both in the network-specific
    // roster and the global roster.
    var user :User;
    user = this.model.getUser(network, profile.userId);
    var oldUserCategories :UserCategories = {
      getTab: null,
      shareTab: null
    };

    if (!user) {
      // New user.
      user = new User(profile.userId, network, this);
      network.roster[profile.userId] = user;
    } else {
      // Existing user, get the category before modifying any properties.
      oldUserCategories = user.getCategories();
    }

    user.update(payload);

    for (var i = 0; i < payload.allInstanceIds.length; ++i) {
      this.mapInstanceIdToUser_[payload.allInstanceIds[i]] = user;
    }

    for (var i = 0; i < payload.offeringInstances.length; i++) {
      var gettingState = payload.offeringInstances[i].localGettingFromRemote;
      var instanceId = payload.offeringInstances[i].instanceId;
      if (gettingState === social.GettingState.GETTING_ACCESS) {
        this.startGettingInUiAndConfig(instanceId, payload.offeringInstances[i].activeEndpoint);
        break;
      } else if (gettingState === social.GettingState.TRYING_TO_GET_ACCESS) {
        this.instanceTryingToGetAccessFrom = instanceId;
        this.updateGettingStatusBar_();
        break;
      }
    }

    for (var i = 0; i < payload.instancesSharingWithLocal.length; i++) {
      this.instancesGivingAccessTo[payload.instancesSharingWithLocal[i]] = true;
      user.isGettingFromMe = true;
    }

    var newUserCategories = user.getCategories();
    // Update the user's category in both get and share tabs.
    categorizeUser(user, this.model.contacts.getAccessContacts,
        oldUserCategories.getTab, newUserCategories.getTab);
    categorizeUser(user, this.model.contacts.shareAccessContacts,
        oldUserCategories.shareTab, newUserCategories.shareTab);
    this.updateBadgeNotification_();

    console.log('Synchronized user.', user);
  };

  public openTab = (url :string) => {
    this.browserApi.openTab(url);
  }

  public bringUproxyToFront = () => {
    this.browserApi.bringUproxyToFront();
  }

  public login = (network :string) : Promise<void> => {
    return this.core.login({ network : network, reconnect: false }).then(() => {
      this.browserApi.hasInstalledThenLoggedIn = true;
    }).catch((e :Error) => {
      this.showNotification(this.i18n_t("ERROR_SIGNING_IN", {network: network}));
      throw e;
    });
  }

  public logout = (networkInfo :social.SocialNetworkInfo) : Promise<void> => {
    var network = this.model.getNetwork(networkInfo.name);
    if (network) {
      // if we know about the network, record that we expect this logout to
      // happen
      network.logoutExpected = true;
    } else {
      console.warn('User is trying to log out of not-logged-in-network ' +
                   networkInfo.name);
    }

    return this.core.logout(networkInfo);
  }

  public reconnect = (network :string) => {
    this.model.reconnecting = true;
    var pingUrl = network == 'Facebook'
        ? 'https://graph.facebook.com' : 'https://www.googleapis.com';
    this.core.pingUntilOnline(pingUrl).then(() => {
      // Ensure that the user is still attempting to reconnect (i.e. they
      // haven't clicked to stop reconnecting while we were waiting for the
      // ping response).
      // TODO: this doesn't work quite right if the user is signed into multiple social networks
      if (this.model.reconnecting) {
        this.core.login({network: network, reconnect: true}).then(() => {
          // TODO: we don't necessarily want to hide the reconnect screen, as we might only be reconnecting to 1 of multiple disconnected networks
          this.stopReconnect();
        }).catch((e) => {
          // Reconnect failed, give up.
          // TODO: this may have only failed for 1 of multiple networks
          this.stopReconnect();
          this.showNotification(
              this.i18n_t("LOGGED_OUT", { network: network }));

          this.updateView_();
        });
      }
    });
  }

  public stopReconnect = () => {
    this.model.reconnecting = false;
  }

  private cloudfrontDomains_ = [
    "d1wtwocg4wx1ih.cloudfront.net"
  ]

  public postToCloudfrontSite = (payload :Object, cloudfrontPath :string,
                                 maxAttempts ?:number)
      : Promise<void> => {
    console.log('postToCloudfrontSite: ', payload, cloudfrontPath);
    if (!maxAttempts || maxAttempts > this.cloudfrontDomains_.length) {
      // default to trying every possible URL
      maxAttempts = this.cloudfrontDomains_.length;
    }
    var attempts = 0;
    var doAttempts = (error ?:Error) : Promise<void> => {
      if (attempts < maxAttempts) {
        // we want to keep trying this until we either run out of urls to
        // send to or one of the requests succeeds.  We set this up by
        // creating a lambda to call the post with failures set up to recurse
        return this.browserApi.frontedPost(payload, this.AWS_FRONT_DOMAIN,
          this.cloudfrontDomains_[attempts++], cloudfrontPath
        ).catch(doAttempts);
      }
      throw error;
    }
    return doAttempts();
  }

  public sendFeedback =
      (feedback :uproxy_core_api.UserFeedback) : Promise<void> => {
    var logsPromise :Promise<string>;
    if (feedback.logs) {
      logsPromise = this.core.getLogs().then((logs) => {
        var browserInfo = 'Browser Info: ' + feedback.browserInfo + '\n\n';
        return browserInfo + logs;
      });
    } else {
      logsPromise = Promise.resolve('');
    }
    return logsPromise.then((logs) => {
      var payload = {
        email: feedback.email,
        feedback: feedback.feedback,
        logs: logs,
        feedbackType: uproxy_core_api.UserFeedbackType[feedback.feedbackType],
        proxyingId: this.proxyingId
      };

      return this.postToCloudfrontSite(payload, 'submit-feedback');
    });
  }

  public setMode = (mode :ui_constants.Mode) => {
    this.model.globalSettings.mode = mode;
    this.core.updateGlobalSettings(this.model.globalSettings);
  }

  public updateLanguage = (newLanguage :string) => {
    this.model.globalSettings.language = newLanguage;
    this.core.updateGlobalSettings(this.model.globalSettings);
    this.i18n_setLng(newLanguage);
  }

  public updateInitialState = (state :uproxy_core_api.InitialState) => {
    console.log('Received uproxy_core_api.Update.INITIAL_STATE:', state);
    this.model.networkNames = state.networkNames;
    this.availableVersion = state.availableVersion;
    if (state.globalSettings.language !== this.model.globalSettings.language) {
      this.updateLanguage(state.globalSettings.language);
    }
    this.model.updateGlobalSettings(state.globalSettings);

    // Maybe refactor this to be copyPasteState.
    this.copyPasteState.updateFromConnectionState(state.copyPasteConnection);

    while (this.model.onlineNetworks.length > 0) {
{'fraction_non_alphanumeric': 0.11416738614288797, 'fraction_numerical': 0.0028329376214116125, 'mean_word_length': 2.9497549019607843, 'pattern_counts': {'":': 0, '<': 33, '<?xml version=': 0, '>': 183, 'https://': 6, 'lorem ipsum': 0, 'www.': 4, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 6, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5498097', 'n_tokens_mistral': 22466, 'n_tokens_neox': 21844, 'n_words': 4914}