hummingbird-me/kitsu-web

View on GitHub
app/components/stream-feed/create-post.js

Summary

Maintainability
D
2 days
Test Coverage
F
18%
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { get, set, setProperties, getProperties, computed } from '@ember/object';
import { isEmpty, isPresent } from '@ember/utils';
import { empty, notEmpty, and, or, equal } from '@ember/object/computed';
import { task, timeout } from 'ember-concurrency';
import { invokeAction, invoke } from 'ember-invoke-action';
import jQuery from 'jquery';
import RSVP from 'rsvp';
import config from 'client/config/environment';
import errorMessages from 'client/utils/error-messages';
import isFileValid from 'client/utils/is-file-valid';
import matches from 'client/utils/elements-match';

const FILE_UPLOAD_LIMIT = 20;
const LINK_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;

export default Component.extend({
  classNameBindings: ['isExpanded:is-expanded'],
  classNames: ['stream-add-content'],
  accept: 'image/jpg, image/jpeg, image/png, image/gif',
  content: undefined,
  isExpanded: false,
  isEditing: false,
  mediaReadOnly: false,
  nsfw: false,
  spoiler: false,
  shouldUnit: false,
  maxLength: 9000,
  _usableMedia: null,
  embedUrl: null,

  ajax: service(),
  store: service(),
  queryCache: service(),
  fileQueue: service(),
  notify: service(),
  raven: service(),

  canPost: or('contentPresent', 'uploadsReady', 'embedUrl'),
  uploadsReady: and('uploadsPresent', 'queueFinished'),
  uploadsPresent: notEmpty('uploads'),
  queueFinished: empty('fileQueue.files'),
  hasMaxUploads: equal('uploads.length', FILE_UPLOAD_LIMIT),

  contentPresent: computed('content', 'embedUrl', function() {
    const hasContent = (isPresent(get(this, 'content'))
      && get(this, 'content.length') <= get(this, 'maxLength'));
    let hasEmbed = isPresent(this.get('embedUrl'));
    if (this.get('isEditing')) {
      hasEmbed = hasEmbed && (this.get('post.embed.url') !== this.get('embedUrl'));
    }
    return hasContent || hasEmbed;
  }).readOnly(),

  uploadCount: computed('fileQueue.files.[]', function() {
    return Math.max(0, Math.min(this.get('fileQueue.files.length'), FILE_UPLOAD_LIMIT));
  }).readOnly(),

  init() {
    this._super(...arguments);
    // copy uploads into our own list
    const uploads = [];
    if (this.get('post.uploads') && this.get('post.uploads.length') > 0) {
      this.get('post.uploads').sortBy('uploadOrder').forEach(upload => uploads.push(upload));
    }
    this.set('uploads', uploads);

    // initialize skipped embeds list
    this.set('skippedEmbeds', []);
  },

  didReceiveAttrs() {
    this._super(...arguments);
    if (get(this, 'forceUnit') === true) {
      set(this, 'shouldUnit', get(this, 'forceUnit'));
    }
    set(this, 'author', get(this, 'session.account'));
    if (get(this, 'isEditing') === true && get(this, 'post')) {
      setProperties(this, {
        _usableMedia: get(this, 'post.media'),
        mediaReadOnly: true,
        content: get(this, 'post.content'),
        contentOriginal: get(this, 'post.content'),
        spoiler: get(this, 'post.spoiler'),
        nsfw: get(this, 'post.nsfw'),
        author: get(this, 'post.user'),
        embedUrl: get(this, 'post.embed.url')
      });
    } else if (get(this, 'media') !== undefined) {
      set(this, '_usableMedia', get(this, 'media'));
      set(this, 'mediaReadOnly', true);
      set(this, 'spoiler', true);
      get(this, 'setUnitNumberTask').perform();
    }
  },

  didInsertElement() {
    this._super(...arguments);
    if (get(this, 'isEditing') === false) {
      jQuery(document.body).on('click.create-post', event => this._handleClick(event));
    }
  },

  willDestroyElement() {
    this._super(...arguments);
    jQuery(document.body).off('click.create-post');
  },

  createPost: task(function* () {
    const options = { ...getProperties(this, 'nsfw', 'spoiler', 'uploads', 'embedUrl') };
    if (this._usableMedia !== null) {
      options.media = this._usableMedia;
    }
    if (get(this, 'shouldUnit') === true && isEmpty(get(this, 'unitNumber')) === false) {
      options.unitNumber = get(this, 'unitNumber');
    }
    yield invokeAction(this, 'onCreate', get(this, 'content'), options);
    this._resetProperties();
  }).drop(),

  getMedia: task(function* (type, query) {
    return yield get(this, 'store').query(type, {
      filter: { text: query },
      page: { limit: 4 }
    });
  }).restartable().maxConcurrency(2),

  setUnitNumberTask: task(function* () {
    if (!get(this, 'session.hasUser') || !isEmpty(get(this, 'unitNumber'))) {
      return;
    }
    const media = get(this, '_usableMedia');
    const type = get(media, 'modelType');
    const results = yield get(this, 'queryCache').query('library-entry', {
      filter: {
        user_id: get(this, 'session.account.id'),
        kind: type,
        [`${type}_id`]: get(media, 'id')
      },
      fields: { libraryEntry: 'progress' }
    });
    const progress = get(results, 'firstObject.progress');
    if (progress > 0) {
      setProperties(this, {
        unitNumber: progress,
        shouldUnit: true
      });
    }
  }).restartable(),

  search: task(function* (query) {
    yield timeout(150);
    const anime = get(this, 'getMedia').perform('anime', query);
    const manga = get(this, 'getMedia').perform('manga', query);
    return yield RSVP.allSettled([anime, manga], 'Search Media').then(states => {
      const fulfilled = states.filter(state => get(state, 'state') === 'fulfilled');
      return fulfilled.map(i => get(i, 'value').toArray()).reduce((a, b) => a.concat(b));
    });
  }).restartable(),

  uploadImagesTask: task(function* (file) {
    const { access_token: accessToken } = get(this, 'session.data.authenticated');
    const headers = {
      accept: 'application/vnd.api+json',
      authorization: `Bearer ${accessToken}`
    };
    try {
      if (this.get('hasMaxUploads') || !isFileValid(get(file, 'blob'), get(this, 'accept'))) {
        set(file, 'state', 'aborted');
        return;
      }

      const { body } = yield file.upload(`${config.kitsu.APIHost}/api/edge/uploads/_bulk`, {
        fileKey: 'files[]',
        headers
      });
      const store = get(this, 'store');
      store.pushPayload(body);
      const uploads = get(this, 'uploads');
      uploads.addObjects(body.data.map(upload => store.peekRecord('upload', upload.id)));
      this._orderUploads(uploads);
    } catch (error) {
      get(this, 'raven').captureException(error);
      get(this, 'notify').error(errorMessages(error));

      const queue = get(this, 'fileQueue').find('uploads');
      const files = get(queue, 'files');
      const failedFiles = files.filter(file => ['failed', 'timed_out'].indexOf(file.state) !== -1);
      failedFiles.forEach(file => {
        set(file, 'state', 'aborted');
      });
    }
  }).enqueue(),

  previewEmbedTask: task(function* () {
    const url = this.get('embedUrl');
    if (!url) { return; }
    return yield this.get('ajax').request('/embeds', {
      method: 'POST',
      data: { url }
    });
  }).restartable(),

  /**
   * If the user clicks outside the bounds of this component
   * then set `isExpanded` to false.
   */
  _handleClick(event) {
    const target = get(event, 'target');
    const isChild = matches(target, '.stream-add-content *, .stream-add-content');
    const isDeleted = jQuery(document.body).find(target).length === 0;
    if (isChild === false && isDeleted === false && get(this, 'isDestroyed') === false) {
      // don't collapse if user has text entered
      if (isEmpty(get(this, 'content')) && !get(this, 'isEditing')) {
        set(this, 'isExpanded', false);
      }
    }
  },

  _resetProperties() {
    if (get(this, 'isEditing') === true) {
      return;
    }

    setProperties(this, {
      content: '',
      isExpanded: false,
      nsfw: false,
      uploads: [],
      skippedEmbeds: [],
      embedUrl: undefined
    });
    if (get(this, 'mediaReadOnly') === false) {
      set(this, '_usableMedia', null);
      set(this, 'spoiler', false);
    }
  },

  _orderUploads(uploads) {
    uploads.forEach(item => set(item, 'uploadOrder', uploads.indexOf(item)));
    set(this, 'uploads', uploads);
  },

  actions: {
    createPost(component, event) {
      const { metaKey, ctrlKey } = event;
      if (metaKey === true || ctrlKey === true) {
        get(this, 'createPost').perform();
      }
    },

    toggleExpand() {
      if (get(this, 'readOnly')) {
        get(this, 'session').signUpModal();
      } else if (!get(this, 'isEditing')) {
        this.toggleProperty('isExpanded');
      }
    },

    paste(event) {
      const { items } = event.clipboardData;
      const images = [];
      for (let i = 0; i < items.length; i += 1) {
        const file = items[i].getAsFile();
        if (file && isFileValid(file, get(this, 'accept'))) {
          event.preventDefault();
          images.push(file);
        }
      }
      if (images && images.length > 0) {
        const queue = get(this, 'fileQueue').find('uploads');
        queue._addFiles(images);
      }
    },

    reorderUploads(orderedUploads) {
      this._orderUploads(orderedUploads);
    },

    removeUpload(upload) {
      upload.destroyRecord();
      get(this, 'uploads').removeObject(upload);
      invoke(this, 'processLinks', this.get('content'));
    },

    // This action is executed everytime the content of the text-area is changed
    processLinks(content, force = false) {
      // reset the skipped embeds if the content is empty (this will be from a deletion)
      if (isEmpty(content) || (!this.get('queueFinished') || this.get('uploads.length') > 0)) {
        this.set('skippedEmbeds', []);
        return;
      }

      // find all the links within the text
      if (force || isEmpty(this.get('embedUrl'))) {
        const links = content.match(LINK_REGEX);
        if (links && links.length > 0) {
          const skipped = this.get('skippedEmbeds');
          const embeds = links.reject(link => skipped.includes(link));
          this.set('embedUrl', embeds.get('firstObject'));
          this.get('previewEmbedTask').perform();
        }
      }
    },

    removeEmbed() {
      const skipped = this.get('skippedEmbeds');
      const embed = this.get('embedUrl');
      skipped.addObject(embed);
      if (isEmpty(this.get('content'))) {
        this.set('embedUrl', undefined);
      }
      invoke(this, 'processLinks', this.get('content'), true);
    }
  }
});