new-client/src/stores/WindowStore.ts

Summary

Maintainability
F
4 days
Test Coverage
import { autorun, observable, computed, makeObservable, action, runInAction } from 'mobx';
import dayjs from 'dayjs';
import { RemirrorJSON } from 'remirror';
import Message from '../models/Message';
import Window, { WindowMoveDirection } from '../models/Window';
import UserModel, { systemUser, me, ircSystemUser } from '../models/User';
import {
  Notification,
  MessageRecord,
  WindowRecord,
  UpdatableWindowRecord,
  Network,
  AlertsRecord,
  Role,
  MessageCategory,
  MessageStatus
} from '../types/notifications';
import WindowModel from '../models/Window';
import { ModalType } from '../models/Modal';
import {
  CreateRequest,
  SendRequest,
  CommandRequest,
  JoinRequest,
  ChatRequest,
  FetchRequest,
  EditRequest,
  CloseRequest,
  UpdatePasswordRequest,
  UpdateTopicRequest,
  UpdateRequest,
  LogoutRequest,
  DestroyAccountRequest
} from '../types/requests';
import RootStore from './RootStore';
import Socket from '../lib/socket';
import { logout } from '../lib/cookie';
import { setUnreadMessageCountBadge } from '../lib/favicon';
import { rootUrl, welcomeUrl, windowUrl } from '../lib/urls';
import MessageModel from '../models/Message';

let nextLocalGid = -1;

const soundAlertElement = new Audio('staple_gun.mp3');
const DesktopNotification = window.Notification;

class WindowStore {
  rootStore: RootStore;
  socket: Socket;
  windows = new Map<number, Window>();
  activeWindow: Window | null = null;
  windowMoveStartingPoint: Array<{ window: Window; desktopId: number; row: number; column: number }> | null = null;
  navigateToPath: { ts: number; path?: string } = { ts: 0 };

  cachedUpto = 0;

  initDone = false;

  constructor(rootStore: RootStore, socket: Socket) {
    this.rootStore = rootStore;
    this.socket = socket;

    makeObservable(this, {
      windows: observable,
      activeWindow: observable,
      navigateToPath: observable,
      initDone: observable,
      desktops: computed,
      windowsArray: computed,
      visibleWindowsGrid: computed,
      windowMoveInProgress: computed,
      totalUnreadMessages: computed,
      addWindow: action,
      updateWindow: action,
      deleteWindowById: action,
      addMessage: action,
      closeWindow: action,
      addError: action,
      sendText: action,
      finishStartup: action,
      handleToggleShowMemberList: action,
      changeActiveWindowById: action,
      updateWindowAlerts: action,
      navigateTo: action,
      removeUser: action,
      startWindowMove: action,
      cancelWindowMove: action,
      saveWindowMove: action,
      moveWindow: action,
      moveWindowToDesktop: action
    });

    autorun(() => {
      this.initDone && setUnreadMessageCountBadge(this.totalUnreadMessages);
    });
  }

  get desktops(): { id: number; name: string; windows: Window[] }[] {
    const desktops: { [key: number]: { windows: Window[] } } = {};

    this.windows.forEach(window => {
      const { desktopId } = window;
      desktops[desktopId] = desktops[desktopId] ?? { windows: [] };
      desktops[desktopId].windows.push(window);
    });

    return Object.entries(desktops).map(([desktopId, value], index) => ({
      id: parseInt(desktopId),
      windows: value.windows,
      name: `Desktop #${index}`
    }));
  }

  get windowsArray(): Array<Window> {
    return Array.from(this.windows.values());
  }

  get visibleWindowsGrid(): Array<Array<Window>> {
    const activeWindow = this.activeWindow;

    if (!activeWindow) {
      return [];
    }

    const visibleWindows = this.windowsArray.filter(window => window.desktopId === activeWindow.desktopId);
    const rows = [...new Set(visibleWindows.map(window => window.row))].sort();

    return rows.map(row =>
      visibleWindows.filter(window => window.row === row).sort((a: Window, b: Window) => (a.column > b.column ? 1 : -1))
    );
  }

  get windowMoveInProgress(): boolean {
    return !!this.windowMoveStartingPoint;
  }

  get totalUnreadMessages(): number {
    let unreadMessages = 0;

    this.windows.forEach(window => {
      unreadMessages += window.unreadMessageCount;
    });

    return unreadMessages;
  }

  get firstWindow(): Window | null {
    return this.windowsArray[0] || null;
  }

  get fallbackWindow(): Window | null {
    return (
      this.activeWindow || this.windows.get(this.rootStore.profileStore.settings.activeWindowId) || this.firstWindow
    );
  }

  handlerServerNotification(ntf: Notification): boolean {
    switch (ntf.type) {
      case 'ADD_MESSAGE': {
        const { type: _, windowId, ...message } = ntf;
        this.addMessage(windowId, message);
        break;
      }
      case 'ADD_MESSAGES': {
        ntf.messages.forEach(({ windowId, messages }: { windowId: number; messages: Array<MessageRecord> }) => {
          messages.forEach((message: MessageRecord) => {
            this.addMessage(windowId, message);
          });
        });
        break;
      }
      case 'ADD_WINDOW': {
        const { type: _, ...window } = ntf;
        this.addWindow(window);
        break;
      }
      case 'UPDATE_WINDOW': {
        const { type: _, ...window } = ntf;
        this.updateWindow(window);
        break;
      }
      case 'DELETE_WINDOW': {
        this.deleteWindowById(ntf.windowId);
        break;
      }
      case 'FINISH_INIT': {
        this.finishStartup();
        break;
      }
      case 'UPDATE_MEMBERS': {
        this.updateMembers(ntf.windowId, ntf.members, ntf.reset);
        break;
      }
      case 'DELETE_MEMBERS': {
        this.deleteMembers(ntf.windowId, ntf.members);
        break;
      }
      default:
        return false;
    }

    return true;
  }

  async uploadFiles(window: Window, files: File[]): Promise<void> {
    if (files.length === 0) {
      return;
    }

    const formData = new FormData();

    for (const file of files) {
      formData.append('file', file, file.name || 'web-upload.jpg');
    }

    if (this.socket.sessionId) {
      formData.append('sessionId', this.socket.sessionId);
    }

    try {
      const response = await fetch('/api/v1/upload', {
        method: 'POST',
        body: formData
      });

      if (!response.ok) {
        throw `Server error ${response.status}.`;
      }

      const { url } = await response.json();
      this.sendText(window, url.join(' '));
    } catch (e) {
      this.addError(window, 'File upload failed.');
    }
  }

  addError(window: WindowModel, body: string): void {
    this.addMessage(window.id, {
      gid: nextLocalGid,
      userId: systemUser.id,
      ts: dayjs().unix(),
      cat: MessageCategory.Error,
      body,
      status: MessageStatus.Original
    });

    nextLocalGid--;
  }

  addMessage(windowId: number, messageRecord: MessageRecord): void {
    const window = this.windows.get(windowId);

    if (!window) {
      return;
    }

    this.upsertMessage(window, messageRecord, 'messages');
    window.resetLastSeenGid({ onlyIfFocused: true });
  }

  async sendText(window: WindowModel, text: string, doc?: RemirrorJSON): Promise<void> {
    let sent = false;

    setTimeout(() => {
      if (!sent) {
        window.notDelivered = true;
      }
    }, 2500);

    const response = await this.socket.send<SendRequest>({
      id: 'SEND',
      text,
      doc,
      windowId: window.id
    });

    sent = true;
    window.notDelivered = false;

    if (response.status !== 'OK') {
      this.rootStore.modalStore.openModal({ type: ModalType.Info, title: 'Error', body: response.errorMsg as string });
    } else {
      const gid = response.gid as number;
      const timestamp = response.ts as number;

      runInAction(() => {
        this.addMessage(window.id, {
          gid,
          userId: me.id,
          ts: timestamp,
          cat: MessageCategory.Message,
          body: text,
          doc,
          status: MessageStatus.Original
        });
      });
    }
  }

  async sendCommand(window: WindowModel, command: string, params?: string): Promise<void> {
    const response = await this.socket.send<CommandRequest>({
      id: 'COMMAND',
      command,
      params: params || '',
      windowId: window.id
    });

    if (response.status !== 'OK') {
      this.rootStore.modalStore.openModal({ type: ModalType.Info, title: 'Error', body: response.errorMsg as string });
    }
  }

  async createGroup(name: string): Promise<{ success: boolean; errorMsg?: string }> {
    const response = await this.socket.send<CreateRequest>({ id: 'CREATE', name });
    const success = response.status === 'OK';

    return { success, ...(success ? {} : { errorMsg: response.errorMsg }) };
  }

  async joinGroup(name: string): Promise<{ success: boolean; errorMsg?: string }> {
    // TODO: Remove support for channel passwords in mas
    const response = await this.socket.send<JoinRequest>({
      id: 'JOIN',
      network: Network.Mas,
      name
    });
    const success = response.status === 'OK';

    return { success, ...(success ? {} : { errorMsg: response.errorMsg }) };
  }

  async joinIrcChannel(
    name: string,
    network: Network,
    password?: string
  ): Promise<{ success: boolean; errorMsg?: string }> {
    const response = await this.socket.send<JoinRequest>({
      id: 'JOIN',
      name,
      network,
      password
    });
    const success = response.status === 'OK';

    return { success, ...(success ? {} : { errorMsg: response.errorMsg }) };
  }

  async startChat(user: UserModel, network: Network): Promise<void> {
    const response = await this.socket.send<ChatRequest>({
      id: 'CHAT',
      userId: user.id,
      network
    });

    if (response.status !== 'OK') {
      this.rootStore.modalStore.openModal({ type: ModalType.Info, title: 'Error', body: response.errorMsg as string });
    }
  }

  async fetchMessageRange(window: WindowModel, start: number, end: number): Promise<void> {
    const response = await this.socket.send<FetchRequest>({
      id: 'FETCH',
      windowId: window.id,
      start,
      end
    });

    window.logMessages.clear();

    response.msgs.forEach(message => {
      this.upsertMessage(window, message, 'logMessages');
    });
  }

  async fetchOlderMessages(window: WindowModel): Promise<boolean> {
    const response = await this.socket.send<FetchRequest>({
      id: 'FETCH',
      windowId: window.id,
      end: Array.from(window.messages.values()).sort((a, b) => a.gid - b.gid)[0].timestamp,
      limit: 50
    });

    // Window messages are roughly sorted. First are old messages received by FETCH.
    // Then the messages received at startup and at runtime.
    if (!response.msgs) {
      return false;
    }

    response.msgs.forEach(message => {
      this.upsertMessage(window, message, 'messages');
    });

    return response.msgs.length !== 0;
  }

  processLine(window: WindowModel, body: string, doc: RemirrorJSON): void {
    let command;
    let commandParams;

    if (body.charAt(0) === '/') {
      const parts = /^(\S*)(.*)/.exec(body.substring(1));
      command = parts && parts[1] ? parts[1].toLowerCase() : '';
      commandParams = (parts && parts[2] ? parts[2] : '').trim();
    }

    const ircServer1on1 = window.type === '1on1' && window.peerUser === ircSystemUser;

    if (ircServer1on1 && !command) {
      this.addError(window, 'Only commands allowed, e.g. /whois john');
      return;
    }

    if (command === 'help') {
      this.rootStore.modalStore.openModal({ type: ModalType.Help });
      return;
    }

    // TODO: /me on an empty IRC channel is not shown to the sender.

    if (command) {
      this.sendCommand(window, command, commandParams);
      return;
    }

    this.sendText(window, body, doc);
  }

  async editMessage(message: MessageModel, body: string): Promise<void> {
    const response = await this.socket.send<EditRequest>({
      id: 'EDIT',
      windowId: message.window.id,
      gid: message.gid,
      text: body
    });

    if (response.status !== 'OK') {
      this.rootStore.modalStore.openPriorityModal({
        type: ModalType.Info,
        title: 'Error',
        body: response.errorMsg as string
      });
    }
  }

  addWindow(windowRecord: WindowRecord): void {
    const window = this.windows.get(windowRecord.windowId);
    const user = windowRecord.userId ? this.rootStore.userStore.users.get(windowRecord.userId) || undefined : undefined;

    if (window) {
      window.updateGeneration(this.socket.sessionId as string);
      window.updateFromRecord(windowRecord);
    } else {
      this.windows.set(
        windowRecord.windowId,
        new Window({
          id: windowRecord.windowId,
          peerUser: user,
          network: windowRecord.network,
          type: windowRecord.windowType,
          desktopId: windowRecord.desktop,
          generation: this.socket.sessionId,
          topic: windowRecord.topic,
          name: windowRecord.name,
          row: windowRecord.row,
          column: windowRecord.column,
          password: windowRecord.password,
          alerts: windowRecord.alerts,
          role: windowRecord.role,
          isMemberListVisible: !windowRecord.minimizedNamesList
        })
      );

      this.initDone && this.navigateTo(windowUrl({ windowId: windowRecord.windowId }));
    }
  }

  updateWindow(windowRecord: UpdatableWindowRecord): void {
    const window = this.windows.get(windowRecord.windowId);

    if (window) {
      window.updateFromRecord(windowRecord);
    }
  }

  async closeWindow(closedWindow: Window): Promise<void> {
    await this.socket.send<CloseRequest>({
      id: 'CLOSE',
      windowId: closedWindow.id
    });
  }

  deleteWindowById(id: number): void {
    const deletedWindow = this.windows.get(id);

    this.windows.delete(id);

    if (this.activeWindow && this.activeWindow === deletedWindow) {
      const sameDesktopWindow = this.windowsArray.find(window => window.desktopId === deletedWindow?.desktopId);
      const windowId = sameDesktopWindow?.id || this.firstWindow?.id;

      this.navigateTo(windowId ? windowUrl({ windowId }) : welcomeUrl());
    }
  }

  async updatePassword(window: Window, password: string): Promise<{ success: boolean; errorMsg?: string }> {
    const response = await this.socket.send<UpdatePasswordRequest>({
      id: 'UPDATE_PASSWORD',
      windowId: window.id,
      password
    });
    const success = response.status === 'OK';

    return { success, ...(success ? {} : { errorMsg: response.errorMsg }) };
  }

  async updateTopic(window: Window, topic: string | null): Promise<void> {
    await this.socket.send<UpdateTopicRequest>({
      id: 'UPDATE_TOPIC',
      windowId: window.id,
      topic
    });
  }

  async updateWindowAlerts(window: Window, alerts: AlertsRecord): Promise<void> {
    window.alerts = alerts;

    await this.socket.send<UpdateRequest>({
      id: 'UPDATE',
      windowId: window.id,
      alerts
    });
  }

  startWindowMove(): void {
    this.windowMoveStartingPoint = this.windowsArray.map(window => ({
      window,
      desktopId: window.desktopId,
      row: window.row,
      column: window.column
    }));
  }

  cancelWindowMove(): void {
    this.windowMoveStartingPoint?.forEach(entry => {
      const window = entry.window;

      window.desktopId = entry.desktopId;
      window.row = entry.row;
      window.column = entry.column;
    });

    this.windowMoveStartingPoint = null;
  }

  saveWindowMove(): void {
    this.windowsArray.forEach(window => {
      this.socket.send<UpdateRequest>({
        id: 'UPDATE',
        windowId: window.id,
        desktop: window.desktopId,
        column: window.column,
        row: window.row
      });
    });

    this.windowMoveStartingPoint = null;
  }

  moveWindow(movingWindow: Window, direction: WindowMoveDirection): void {
    const windowRowIndex = this.visibleWindowsGrid.findIndex(row => row.includes(movingWindow));
    const windowRow = this.visibleWindowsGrid[windowRowIndex];

    if (!windowRow) {
      return;
    }

    if (direction === WindowMoveDirection.Left || direction === WindowMoveDirection.Right) {
      this.moveInArray(movingWindow, windowRow, direction).forEach((window, index) => {
        window.column = index;
      });
      return;
    }

    if (windowRow.length === 1) {
      if (
        (direction === WindowMoveDirection.Up && windowRowIndex === 0) ||
        (direction === WindowMoveDirection.Down && windowRowIndex === this.visibleWindowsGrid.length - 1)
      ) {
        return;
      }
    }

    movingWindow.row += direction === WindowMoveDirection.Up ? -1 : 1;
  }

  moveWindowToDesktop(movingWindow: Window, desktopId: number | 'new'): void {
    movingWindow.desktopId =
      desktopId === 'new' ? Math.max(...this.desktops.map(desktop => desktop.id)) + 1 : desktopId;
  }

  private moveInArray(
    window: Window,
    array: Array<Window>,
    direction: WindowMoveDirection.Left | WindowMoveDirection.Right
  ): Array<Window> {
    const index = array.indexOf(window);

    if (
      (index === 0 && direction === WindowMoveDirection.Left) ||
      (index === array.length - 1 && direction === WindowMoveDirection.Right)
    ) {
      return array;
    }

    const toIndex = index + (direction === WindowMoveDirection.Left ? -1 : 1);

    const current = array[toIndex];
    array[toIndex] = window;
    array[index] = current;

    return array;
  }

  async handleToggleShowMemberList(window: Window): Promise<void> {
    window.isMemberListVisible = !window.isMemberListVisible;

    await this.socket.send<UpdateRequest>({
      id: 'UPDATE',
      windowId: window.id,
      minimizedNamesList: !window.isMemberListVisible
    });
  }

  finishStartup(): void {
    // Remove possible deleted windows.
    this.windows.forEach(windowObject => {
      if (windowObject.generation !== this.socket.sessionId) {
        this.windows.delete(windowObject.id);
      }
    });

    this.windows.forEach(window => window.resetLastSeenGid()); // TODO: In the future, last seen gid comes from server
    this.startTrackingVisibilityChanges();

    this.initDone = true;
  }

  updateMembers(id: number, members: Array<{ userId: string; role: Role }>, reset: boolean): void {
    const window = this.windows.get(id);

    if (!window) {
      return;
    }

    if (reset) {
      window.operators = [];
      window.voices = [];
      window.users = [];
    }

    members.forEach(member => {
      const user = this.rootStore.userStore.users.get(member.userId);

      if (user) {
        if (!reset) {
          this.removeUser(user, window);
        }

        switch (member.role) {
          case '@':
          case '*':
            window.operators.push(user);
            break;
          case 'v':
            window.voices.push(user);
            break;
          default:
            window.users.push(user);
            break;
        }
      }
    });
  }

  deleteMembers(windowId: number, members: Array<{ userId: string }>): void {
    const window = this.windows.get(windowId);

    if (!window) {
      return;
    }

    members.forEach(member => {
      const user = this.rootStore.userStore.users.get(member.userId);

      if (user) {
        this.removeUser(user, window);
      }
    });
  }

  // TODO: Move these handlers somewhere else

  async handleLogout(allSessions: boolean): Promise<void> {
    await this.socket.send<LogoutRequest>({
      id: 'LOGOUT',
      allSessions: !!allSessions
    });

    logout('User logged out');
  }

  async handleDestroyAccount(): Promise<void> {
    await this.socket.send<DestroyAccountRequest>({
      id: 'DESTROY_ACCOUNT'
    });

    logout('User destroyed the account');
  }

  changeActiveWindowById(windowId: number): void {
    const newActiveWindow = this.windows.get(windowId);

    if (!newActiveWindow) {
      this.navigateTo(rootUrl());
    } else if (newActiveWindow !== this.activeWindow) {
      this.activeWindow?.setFocus(false);
      this.activeWindow = newActiveWindow;
      this.activeWindow.setFocus(true);

      this.rootStore.profileStore.changeActiveWindowId(newActiveWindow);
    }
  }

  navigateTo(path: string): void {
    this.navigateToPath = { ts: Date.now(), path };
  }

  private upsertMessage(window: WindowModel, message: MessageRecord, type: 'messages' | 'logMessages'): void {
    const existingMessage = window[type].get(message.gid);

    if (existingMessage) {
      existingMessage.updateFromRecord(message);
      return;
    }

    const user = this.rootStore.userStore.users.get(message.userId);
    const networkSystemUser = window.network === Network.Mas ? systemUser : ircSystemUser;

    const messageModel = new Message({
      user: user || networkSystemUser,
      window,
      gid: message.gid,
      body: message.body,
      doc: message.doc,
      category: message.cat,
      timestamp: message.ts,
      updatedTimestamp: message.updatedTs,
      status: message.status
    });

    window[type].set(message.gid, messageModel);

    this.newMessageAlert(window, messageModel);
  }

  private newMessageAlert(window: WindowModel, message: Message) {
    if (!message.isNotable || message.isFromMe || !this.initDone) {
      return;
    }

    if (window.alerts.sound) {
      soundAlertElement.pause();
      soundAlertElement.currentTime = 0;
      soundAlertElement.play().catch(e => {
        console.log(`Sound notification failed, reason: ${e}`);
      });
    }

    if (window.alerts.notification) {
      const notificationsSupported = typeof DesktopNotification === 'function';

      if (notificationsSupported && DesktopNotification.permission === 'granted') {
        const ntf = new DesktopNotification(`${message.nick} (${window.type})`, {
          body: message.body,
          icon: message.avatarUrl
        });

        ntf.onclick = () => this.navigateTo(windowUrl({ windowId: window.id }));
      }
    }

    if (window.alerts.title) {
      window.unreadMessageCount++;
    }
  }

  removeUser(user: UserModel, window: WindowModel): void {
    window.operators = window.operators.filter(existingUser => user !== existingUser);
    window.voices = window.voices.filter(existingUser => user !== existingUser);
    window.users = window.users.filter(existingUser => user !== existingUser);
  }

  private startTrackingVisibilityChanges(): void {
    document.addEventListener('visibilitychange', () => {
      this.activeWindow?.setFocus(document.visibilityState === 'visible');
    });
  }
}

export default WindowStore;