Client.js

import Account from "./models/Account.js";
import Identity from "./models/Identity.js";
import Message from "./models/Message.js";
import PrivateData from "./models/PrivateData.js";

import WebSocketEndpoint from "./endpoints/WebSocketEndpoint.js";
import AuthEndpoint from "./endpoints/AuthEndpoint.js";
import IdentitiesEndpoint from "./endpoints/IdentitiesEndpoint.js";
import MessagesEndpoint from "./endpoints/MessagesEndpoint.js";
import PrivateDataEndpoint from "./endpoints/PrivateDataEndpoint.js";
import FriendsEndpoint from "./endpoints/FriendsEndpoint.js";

import { toArray, intersection, unique } from "./util.js";

/**
 * autherror: detail is {message, handle}
 */

/**
 * Object passed to details on CustomEvents about messages fired by the Client.
 * @typedef {object} messageEventDetails
 * @property {string} messageId the id of the message the event is referring to
 * @property {string} handle the handle the event is associated with.
 */

/**
 * Delete Message Event. Called when a message is deleted.
 *
 * When a message is deleted it no longer exists on the server so you cannot call
 * [getMessageById]{@link Client#getMessageById} to get information about a deleted message.
 * @event messagedeletion
 * @type {CustomEvent}
 * @property {messageEventDetails} detail the detail contains a messageEventDetails with the id and handle name of the message which was deleted.
 */

/**
 * Update Message Event
 *
 * Called when a message is updated. For example if the data changes. Use
 * [getMessageById]{@link Client#getMessageById} to get information about the updatedMessage such as the
 * new data.
 * @event messageupdate
 * @type {CustomEvent}
 * @property {messageEventDetails} detail the detail contains a messageEventDetails with the id and handle name of the message which was updated.
 */

/**
 * New Message Event
 *
 * Called when a new message is available. Call [getMessageById]{@link Client#getMessageById} to get
 * information about the new message.
 * @event message
 * @type {CustomEvent}
 * @property {messageEventDetails} detail the detail contains a messageEventDetails with the id and handle name of the message which was created.
 */

/**
 * ```js
 * import Client from "https://designftw.github.io/chat-lib/src/Client.js";
 * ```
 *
 * The Client is the interface for interacting with the ChatServer.
 *
 *
 * @fires messagedeletion
 * @fires messageupdate
 * @fires message
 */
export default class Client extends EventTarget {
  /**
   * The host of the chat server. Includes the hostname and port.
   * @type {string}
   */
  #host;

  /**
  * Helper class for authorization routes.
  * @type {AuthEndpoint}
  */
  #auth;

  /**
  * Helper class for handle routes.
  * @type {IdentitiesEndpoint}
  */
  #identities;

  /**
  * Helper class for message routes.
  * @type {MessagesEndpoint}
  */
  #messages;

  /**
  * Helper class for private data routes.
  * @type {PrivateDataEndpoint}
  */
  #privateData;

  /**
  * Helper class for friends routes.
  * @type {FriendsEndpoint}
  */
  #friends;

  /**
   * Helper class for web socket routes.
   * @type {WebSocketEndpoint}
   */
  #webSocket;

  /**
   * Client constructor.
   * @param {string} host see [Client's host property]{@link Client#host}
   */
  constructor(host) {
    super();

    this.#host = host;
    this.#auth = new AuthEndpoint(this);
    this.#identities = new IdentitiesEndpoint(this);
    this.#messages = new MessagesEndpoint(this);
    this.#privateData = new PrivateDataEndpoint(this);
    this.#friends = new FriendsEndpoint(this);
    this.#webSocket = new WebSocketEndpoint(this);

    /**
    * The logged in client account. Set in [login]{@link Client#login} and
    * unset in [logout]{@link Client#logout}.
    * @type {Account | undefined}
    */
    this.account = undefined;

    // Redirect all events from private objects to the Client object
    for (let event of ["message", "messageupdate", "messagedeletion", "autherror"]) {
      this.#webSocket.addEventListener(event, evt => {
        let evtCopy = new CustomEvent(evt.type, { detail: evt.detail });
        this.dispatchEvent(evtCopy);
      });
    }
  }

  /**
   * The host of the chat server. Includes the hostname and port.
   * For example https://mozilla.org:4000.
   * Read-only.
   *
   * See https://developer.mozilla.org/en-US/docs/Web/API/URL/host
   * @type {string}
   */
  get host() {
    return this.#host;
  }

  /**
   * Sign up for a new account with a single initial handle.
   * @param {string} handle The name of the initial handle if signup is successful.
   * @param {string} email The email address associated with the account.
   * @param {string} password The password associated with the account.
   * @returns {Promise<{message: string}>} A validation message
   */
  signup(handle, email, password) {
    return this.#auth.signup(handle, email, password);
  }

  /**
   * Login to an existing account.
   * @param {string} email The email address associated with the account.
   * @param {string} password The password associated with the account.
   * @returns {Promise<Account>} Upon success returns the account which was logged in.
   */
  async login(email, password) {
    let account = await this.#auth.login(email, password);
    this.#postLogin(account);
    return this.account;
  }

  /**
   * Logout of an existing account
   */
  async logout() {
    await this.#auth.logout();
    this.unsubscribe(this.account.handle);
    this.account = undefined;
    this.dispatchEvent(new CustomEvent("logout"));
    return;
  }

  /**
   * Check if the user is currently logged in
   * @returns {Promise<Account | null>} Returns the account if the user is logged in, otherwise returns undefined.
   */
  async getLoggedInAccount() {
    let {isLoggedIn, account} = await this.#auth.isLoggedIn();

    if (!isLoggedIn) {
      return null;
    }

    this.#postLogin(account);

    return account;
  }

  #postLogin(account) {
    this.account = account;
    this.subscribe(this.account.handle);
    this.dispatchEvent(new CustomEvent("login", { detail: { account: this.account } }));
  }

  /**
   * Get an array of all messages that match the provided filters (see below).
   * Note that regardless of the filters provided, only messages the currently logged in account has access to will be returned.
   *
   * @param {Object} options
   * @param {string | Identity | string[] | Identity[]} [options.from] Sender(s) of the messages, either as handle(s) or Identity object(s)
   * @param {string | Identity | string[] | Identity[]} [options.to] Recipient(s) of the messages, either as handle(s) or Identity object(s)
   * @param {string | Identity | string[] | Identity[]} [options.participants] Senders OR recipient(s) of the messages, either as handle(s) or Identity object(s)
   * @param {Date} [options.since] an optional date to limit the request by. only receive messages since this date.
   * @param {"any"|"all"|"exact"} [options.match="any"] The policy to use when filtering messages by sender, recipient or participants.
   * - "any" would return messages with any of the parties specified
   * - "all" would return messages with all of the parties specified (but more are possible)
   * - "exact" would return messages with the exact parties specified
   * @param {boolean} [options.exact = false] Deprecated. Please use `match` instead. Do we want to return messages that match the `to` and `participants` params exactly, or messages that contain at least one handle in each of these arrays?
   * E.g. if we specify `to: ["A", "B"]`, do we want messages sent to either A, or B, or both, or both plus some other people, or messages sent to exactly A and B?
   * @returns {Promise<Message[]>} a list of messages which pass the filters.
   */
  async getMessages({ from = [], to = [], participants = [], since, match, exact } = {}) {
    // Normalize senders and recipients to arrays of handles
    from = toArray(from).map(sender => sender instanceof Identity? sender.handle : sender);
    from = unique(from);
    to = toArray(to).map(recipient => recipient instanceof Identity? recipient.handle : recipient);
    to = unique(to);
    participants = toArray(participants).map(recipient => recipient instanceof Identity? recipient.handle : recipient);
    participants = unique(participants);

    if (exact !== undefined && match === undefined) {
      console.warn("[client.getMessages()] Warning: `exact` is deprecated. Please use `match` instead.");
      match = exact ? "all" : "any";
    }

    match = match || "any";

    if (from.length > 1 && match !== "any") {
      throw new Error("Cannot specify more than one sender in ‘from’ when we are looking for exact matches, since messages only have one sender.");
    }

    // Actual filter for API
    let interlocutors = unique([...from, ...to, ...participants]);

    // alias in this case is not a filter, the api just checks that the
    // account owns the message
    let messages = await this.#messages.getMessagesForAlias(this.account.handle, interlocutors, since);

    messages = await Promise.all(messages.map(message => this.#messages.getMessage(this.account.handle, message.id)));

    // Filter messages to only those that match the provided filters
    messages = messages.filter(message => {
      let sender = message.sender.handle;

      if (from.length > 0 && !from.includes(sender)) {
        // Filtering by sender(s)
        return false;
      }

      let recipients = message.recipients.map(recipient => recipient.handle);

      if (to.length > 0) {
        // Filtering by recipient(s)
        let toIntersection = intersection(recipients, to);

        if (toIntersection.size === 0) {
          // If the intersection is empty, we should return false regardless of the match policy
          return false;
        }

        if (match !== "any") {
          // Only if match == any the intersection can be smaller than the to array
          if (toIntersection.size < to.length) {
            return false;
          }

          if (match === "exact") {
            // If we are looking for exact matches, we should return false there are more recipients than specified
            if (to.length !== recipients.length) {
              return false;
            }
          }
        }
      }

      if (participants.length > 0) {
        // We were also filtering by participants
        // We only need to do something here if match != any,
        // otherwise the filtering is already done on the server
        if (match !== "any") {
          let messageParticipants = unique([sender, ...recipients]);

          let participantIntersection = intersection(messageParticipants, participants);

          if (participantIntersection.size < participants.length) {
            return false;
          }

          if (match === "exact") {
            // If we are looking for exact matches, we should return false there are more participants than specified
            if (participants.length !== messageParticipants.length) {
              return false;
            }
          }
        }
      }

      return true;
    });

    return messages;
  }

  /**
   * Get a message object for a message id or
   * Get a message which has the passed in messageId, and was sent or received by the passed in handle.
   *
   * Note the currently logged in account must own the handle associated with the handle.
   * @param messageId The id of the message to get.
   * @param {Object} options
   * @param {string} [options.handle] the handle which sent or received the message, as an optional safeguard against race conditions.
   * @returns {Promise<Message>} The model of the message with the associated id.
   */
  getMessageById(messageId, { handle = this.account.handle } = {}) {
    return this.#messages.getMessage(handle, messageId);
  }

  /**
   * Send a message from the passed in handle to the passed in recipients with the passed in data.
   *
   * Note the currently logged in account must own the handle associated with the handle.
   * @param {Object} options
   * @param {string | Identity} options.from the sender, either a handle or Identity object
   * @param {string | string[] | Identity | Identity[]} options.to One or more recipients of the message, either as handles or Identity objects
   * @param {Object} options.data the data associated with the message.
   * @returns {Promise<Message>} The model of the sent message.
   */
  sendMessage({from = this.account.handle, to, data}) {
    from = from instanceof Identity? from.handle : from;
    to = toArray(to).map(recipient => recipient instanceof Identity? recipient.handle : recipient);
    console.log(from, to, data);
    return this.#messages.sendMessage(from, to, data);
  }

  /**
   * Update a message with the passed in message id which was sent by the passed in handle.
   * @param {Message | string} message Message object or message id
   * @param {Object} options
   * @param {string} [options.handle] the handle which sent the message.
   * @param {Object} options.data the updated data associated with the message
   * @returns {Promise<Message>} The Message object of the updated message.
   */
  updateMessage(message, {handle = this.account.handle, data}) {
    let id = message instanceof Message? message.id : message;
    return this.#messages.updateMessage(handle, id, data);
  }

  /**
   * Delete a message with the passed in messageId which was sent by the passed in handle.
   * @param {Message | string} message Message object or message id
   * @param {Object} [options]
   * @param {string} [options.handle] the handle which sent the message.
   * @returns {Promise<any>} a validation message.
   */
  deleteMessage(message, {handle = this.account.handle} = {}) {
    let id = message instanceof Message? message.id : message;
    return this.#messages.deleteMessage(handle, id);
  }

  /**
   * Get all the handles for the currently logged in account.
   * @returns {Promise<Identity[]>} An array of Alias models.
   */
  async getIdentities() {
    return this.#identities.getAliasesForAccount();
  }

  /**
   * Get an identity by its handle.
   * If an Identity object is passed in, it will just be returned.
   * This way this function can be used to normalize a handle | Identity object into an Identity object.
   *
   * @param {string | Identity} handle the handle to get
   * @returns {Promise<Identity>} The Alias model associated with the passed in name
   */
  async getIdentity(handle) {
    if (handle instanceof Identity) {
      return handle;
    }

    return this.#identities.getAlias(handle);
  }

  /**
   * Create a new identity with the passed in handle and data.
   * @param {Object} options
   * @param {string} options.handle the name of the new handle
   * @param {Object} [options.data] the data to associate with the handle
   * @returns {Promise<Identity>} The Identity object that corresponds to the new handle
   */
  createIdentity({handle, data}) {
    return this.#identities.createAlias(handle, data);
  }

  /**
   * Update an existing identity.
   * @param {string} handle the handle to update.
   * @param {Object} updates object containing the updates to the handle.
   * @param {Object} [updates.data] the optional new data for the handle
   * @returns {Promise<Identity>} The Identity object that was updated
   */
  updateIdentity(handle, { data: newData } = {}) {
    return this.#identities.updateAlias(handle, {newData});
  }

  /**
   * Delete the identity associated with the passed in handle name.
   * @param {string} handle the handle to delete.
   * @returns {Promise<any>} A validation message.
   */
  deleteIdentity(handle) {
    if (handle === this.account.handle) {
      throw new Error("Cannot delete the default identity for the logged in account");
    }
    return this.#identities.deleteAlias(handle);
  }

  /**
   * Create a new private data for the passed in entity, private to the passed in handle.
   * @param {Object} options
   * @param {string} options.handle the handle which is creating the data.
   * @param {string} options.entityId the [id]{@link BaseModel#id} of the entity ({@link Message}, {@link Alias}, or {@link Account}) the data is attached to.
   * @param {Object} options.data the data to attach to the entity associated with the passed in entityId, private to the handle associated with the passed in handle.
   * @returns {Promise<PrivateData>} the new private data.
   */
  createPrivateData({handle, entityId, data}) {
    handle = handle ?? this.account.handle;
    return this.#privateData.createPayload(handle, entityId, data);
  }

  /**
   * Get the private data associated with the passed in handle and entity.
   * @param {Object} options
   * @param {string} options.handle the handle associated with the data to get.
   * @param {string} options.entityId the [id]{@link BaseModel#id} of the entity ({@link Message}, {@link Alias}, or {@link Account}) the data to get is attached to.
   * @returns {Promise<PrivateData>} the private data associated with the passed in handle and entity.
   */
  getPrivateData({handle, entityId}) {
    handle = handle ?? this.account.handle;
    return this.#privateData.getPayload(handle, entityId);
  }

  /**
   * Update the private data associated with the passed in handle and entity.
   * @param {Object} options
   * @param {string} options.handle the handle associated with the data to update.
   * @param {string} options.entityId the [id]{@link BaseModel#id} of the entity ({@link Message}, {@link Alias}, or {@link Account}) the data to update is attached to.
   * @param {Object} options.newData the new private data.
   * @returns {Promise<PrivateData>} the updated private data.
   */
  updatePrivateData({handle = this.account.handle, entityId, newData}) {
    return this.#privateData.updatePayload(handle, entityId, newData);
  }

  /**
   * Delete the private data associated with the passed in handle and entity.
   * @param {Object} options
   * @param {string} options.handle the handle associated with the data to delete.
   * @param {string} options.entityId the [id]{@link BaseModel#id} of the entity ({@link Message}, {@link Alias}, or {@link Account}) the data to delete is attached to.
   * @returns {Promise<any>} A validation message.
   */
  deletePrivateData({handle = this.account.handle, entityId}) {
    return this.#privateData.deletePayload(handle, entityId);
  }

  /**
   * Get all the friends of the passed in handle.
   * @param {string} handle the handle whose friends will be retrieved.
   * @returns {Promise<Identity[]>} an array of handles. These are the passed in handle's friends.
   */
  getFriends(handle = this.account.handle) {
    return this.#friends.getFriendsForAlias(handle);
  }

  /**
   * Add a new friend to the friend list of the passed in handle.
   * @param {string} friendHandle the handle which is going to be added to ownAlias's friend list.
   * @param {Object} options
   * @param {string} [options.ownHandle] the handle adding a friend.
   * @returns {Promise<any>} a validation message.
   */
  addFriend(friendHandle, {ownHandle} = {}) {
    ownHandle = ownHandle ?? this.account.handle;
    return this.#friends.addFriend(ownHandle, friendHandle);
  }

  /**
   * Remove a friend from the friend list of the passed in handle.
   * @param {string} friendHandle the handle which is going to be removed from ownAlias's friend list.
   * @param {Object} options
   * @param {string} [options.ownHandle] the handle removing a friend.
   * @returns {Promise<any>} a validation message.
   */
  removeFriend(friendHandle, {ownHandle} = {}) {
    ownHandle = ownHandle ?? this.account.handle;
    return this.#friends.removeFriend(ownHandle, friendHandle);
  }

  /**
   * Given an array of Messages group the messages by unique groups of sender
   * and recipient handles. For example if Alice sends Bob a message and Bob
   * sends Alice a message both of those messages are in the same group since
   * the set of sender and recipient handles contains {Alice, Bob} for each
   * message.
   *
   * @param {Message[]} messages the messages to group by unique groups of senders and recipients.
   * @returns {Message[][]} an array where each element is a set of messages with a unique set of senders and recipients.
   */
  groupMessagesByUniqueRecipients(messages) {
    const msgKeyedByInterlocutorSets = messages.reduce(
      (
        r,
        v,
        i,
        a,
        k = [...new Set([v.sender.id, ...v.recipients.map((c) => c.id)])]
          .sort()
          .join("")
      ) => ((r[k] || (r[k] = [])).push(v), r),
      {}
    );

    return Object.values(msgKeyedByInterlocutorSets);
  }

  async subscribe(handle) {
    await this.#webSocket.openWebSocketFor(handle);
  }

  unsubscribe(handle) {
    this.#webSocket.closeWebSocketFor(handle);
  }
}