huridocs/uwazi

View on GitHub
app/react/Markdown/components/MarkdownMedia.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
A
91%
/* eslint-disable max-lines */
/* eslint-disable max-statements */
/* eslint-disable react/jsx-props-no-spreading */
import React, { useState, useRef, useEffect } from 'react';
import { FieldArrayWithId, useFieldArray, useForm } from 'react-hook-form';
import ReactPlayer from 'react-player';
import { Icon } from 'UI';
import { Translate } from 'app/I18N';
import { validMediaFile } from 'app/Metadata/helpers/validator';

interface MarkdownMediaProps {
  compact?: boolean;
  editing?: boolean;
  onTimeLinkAdded?: Function;
  config: string;
  type?: string;
}

interface TimeLink {
  timeHours: string;
  timeMinutes: string;
  timeSeconds: string;
  label: string;
}

const propsToConfig = (props: MarkdownMediaProps) => {
  const config = { url: '', options: { timelinks: {} } };

  let parsedProps: any = props.config.replace(/\(|\)/g, '').split(',');
  config.url = parsedProps.shift() as string;

  parsedProps = (parsedProps.join(',') || '{}').replace(/"/g, '"');

  try {
    parsedProps = JSON.parse(parsedProps);
  } catch (error) {
    parsedProps = {};
  }

  config.options = parsedProps;

  return config;
};

const formatTimeLinks = (timelinks: any): TimeLink[] =>
  Object.keys(timelinks).map(key => {
    const timeLink = { timeHours: '00', timeMinutes: '00', timeSeconds: '00', label: '' };
    const splitTimes = key.split(':');
    if (splitTimes.length === 2) {
      [timeLink.timeMinutes, timeLink.timeSeconds] = splitTimes;
    } else {
      [timeLink.timeHours, timeLink.timeMinutes, timeLink.timeSeconds] = splitTimes;
    }
    timeLink.label = timelinks[key] as string;
    return timeLink;
  });

const MarkdownMedia = (props: MarkdownMediaProps) => {
  //Given the testing conditions, ref should be declared in this specific order.
  const containerRef = useRef<HTMLDivElement>(null);
  const playerRef = useRef<ReactPlayer>(null);

  const [newTimeline, setNewTimeline] = useState<TimeLink>({
    timeHours: '',
    timeMinutes: '',
    timeSeconds: '',
    label: '',
  });
  const [errorFlag, setErrorFlag] = useState(false);
  const { options } = propsToConfig(props);
  const originalTimelinks = formatTimeLinks(options?.timelinks || {});
  const [playingTimelinkIndex, setPlayingTimelinkIndex] = useState<number>(-1);
  const [isVideoPlaying, setVideoPlaying] = useState<boolean>(false);
  const [temporalResource, setTemporalResource] = useState<string>();
  const [mediaURL, setMediaURL] = useState('');
  const [isLoading, setIsLoading] = useState(true);
  const { control, register, getValues } = useForm<{ timelines: TimeLink[] }>({
    defaultValues: { timelines: originalTimelinks },
  });
  const { fields, remove, append } = useFieldArray<{ timelines: TimeLink[] }>({
    control,
    name: 'timelines',
  });

  const validMediaUrlRegExp =
    /(^(blob:)?https?:\/\/(?:www\.)?)[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]$/;

  const seekTo = (seconds: number) => {
    const playingStatus = isVideoPlaying;
    playerRef.current?.seekTo(seconds);
    setVideoPlaying(playingStatus);
  };

  const timeLinks = (_timelinks: any) => {
    const timelinks = _timelinks || {};
    return Object.keys(timelinks).map((timeKey, index) => {
      const linkIndex = index;
      const seconds = timeKey
        .split(':')
        .reverse()
        .reduce((_seconds, n, _index) => _seconds + parseInt(n, 10) * 60 ** _index, 0);

      let displayTime = timeKey;
      if (displayTime.split(':').length === 2) {
        displayTime = `00:${displayTime}`;
      }

      return (
        <div className="timelink" key={timeKey + index.toString()} title={timelinks[timeKey]}>
          <b
            className="timelink-icon"
            onClick={() => {
              seekTo(seconds);
              if (linkIndex === playingTimelinkIndex) {
                setVideoPlaying(false);
                setPlayingTimelinkIndex(-1);
              } else {
                setVideoPlaying(true);
                setPlayingTimelinkIndex(linkIndex);
              }
            }}
          >
            <Icon icon={linkIndex === playingTimelinkIndex ? 'pause' : 'play'} />
          </b>
          <div
            className="timelink-time-label"
            onClick={() => {
              seekTo(seconds);
              setPlayingTimelinkIndex(linkIndex);
            }}
          >
            <b>{displayTime}</b>
            <span>{timelinks[timeKey]}</span>
          </div>
        </div>
      );
    });
  };

  const updateParentForm = () => {
    if (props.onTimeLinkAdded) props.onTimeLinkAdded(getValues().timelines);
  };

  const appendTimelinkAndUpdateParent = (timelink?: TimeLink) => {
    const currentTimelink = timelink || newTimeline;
    append(currentTimelink);
    updateParentForm();
  };

  const renderNewTimeLinkForm = () => (
    <div className="new-timelink">
      <div className="timestamp">
        <input
          type="number"
          onChange={event => {
            const hours = parseInt(event.target.value || '0', 10);
            setNewTimeline({ ...newTimeline, timeHours: hours <= 0 ? '00' : hours.toString() });
          }}
          className="timestamp-hours"
          placeholder="00"
          value={newTimeline.timeHours}
          min="0"
        />
        <span className="seperator">:</span>
        <input
          type="number"
          onChange={event => {
            let minutes = parseInt(event.target.value || '0', 10);
            minutes = minutes > 59 ? 59 : minutes;
            setNewTimeline({
              ...newTimeline,
              timeMinutes: minutes <= 0 ? '00' : minutes.toString(),
            });
          }}
          className="timestamp-minutes"
          placeholder="00"
          value={newTimeline.timeMinutes}
          min="0"
          max="59"
        />
        <span className="seperator">:</span>
        <input
          type="number"
          onChange={event => {
            let seconds = parseInt(event.target.value || '0', 10);
            seconds = seconds > 59 ? 59 : seconds;
            setNewTimeline({
              ...newTimeline,
              timeSeconds: seconds <= 0 ? '00' : seconds.toString(),
            });
          }}
          className="timestamp-seconds"
          placeholder="00"
          value={newTimeline.timeSeconds}
          min="0"
          max="59"
        />
      </div>
      <input
        type="text"
        onChange={event => {
          setNewTimeline({ ...newTimeline, label: event.target.value });
        }}
        className="timestamp-label"
        placeholder="Enter title"
        value={newTimeline.label}
      />
      <button
        type="button"
        className="new-timestamp-btn"
        onClick={() => {
          const time = {
            timeHours: newTimeline.timeHours || '00',
            timeMinutes: newTimeline.timeMinutes || '00',
            timeSeconds: newTimeline.timeSeconds || '00',
            label: newTimeline.label,
          };

          appendTimelinkAndUpdateParent(time);
          setNewTimeline({ timeHours: '', timeMinutes: '', timeSeconds: '', label: '' });
        }}
      >
        <Icon icon="plus" />
      </button>
    </div>
  );

  const renderSingleTimeLinkInputs = (field: FieldArrayWithId<TimeLink>, index: number) => (
    <div className="new-timelink" key={index}>
      <div className="timestamp">
        <input
          type="text"
          className="timestamp-hours"
          placeholder="00"
          key={`${field.id}hours`}
          {...register(`timelines.${index}.timeHours`, {
            onChange: _ => updateParentForm(),
          })}
        />
        <span className="seperator">:</span>
        <input
          type="text"
          className="timestamp-minutes"
          placeholder="00"
          key={`${field.id}minutes`}
          {...register(`timelines.${index}.timeMinutes`, {
            onChange: _ => updateParentForm(),
          })}
        />
        <span className="seperator">:</span>
        <input
          type="text"
          className="timestamp-seconds"
          placeholder="00"
          key={`${field.id}seconds`}
          {...register(`timelines.${index}.timeSeconds`, {
            onChange: _ => updateParentForm(),
          })}
        />
      </div>
      <input
        type="text"
        className="timestamp-label"
        placeholder="Enter title"
        key={field.id}
        {...register(`timelines.${index}.label`, {
          onChange: _ => updateParentForm(),
        })}
      />
      <button
        title="Remove timelink"
        type="button"
        className="delete-timestamp-btn"
        onClick={() => {
          remove(index);
          updateParentForm();
        }}
      >
        <Icon icon="trash-alt" />
      </button>
    </div>
  );

  const renderTimeLinksForm = () => (
    <>
      {fields.map((field: FieldArrayWithId<TimeLink>, index: number) =>
        renderSingleTimeLinkInputs(field, index)
      )}
      {renderNewTimeLinkForm()}
    </>
  );

  const config = propsToConfig(props);

  useEffect(() => {
    let url: string;

    if (config.url.startsWith('/api/files/')) {
      fetch(config.url)
        .then(async res => {
          if (validMediaFile(res)) {
            return res.blob();
          }
          setErrorFlag(true);
          throw new Error('Invalid file');
        })
        .then(blob => {
          setErrorFlag(false);
          url = URL.createObjectURL(blob);
          setMediaURL(url);
        })
        .catch(_e => {})
        .finally(() => {
          setIsLoading(false);
        });
    } else if (config.url.match(validMediaUrlRegExp)) {
      setErrorFlag(false);
      setMediaURL(config.url);
      setIsLoading(false);
    } else {
      if (mediaURL && mediaURL.match(validMediaUrlRegExp) && !temporalResource) {
        setTemporalResource(mediaURL);
      }
      setMediaURL(config.url);
      setIsLoading(false);
    }

    return () => {
      setErrorFlag(false);
      setIsLoading(true);
      URL.revokeObjectURL(url);
      setMediaURL('');
    };
  }, [config.url]);

  useEffect(() => () => {
    if (isVideoPlaying) {
      setVideoPlaying(false);
    }
  });

  useEffect(() => {
    if (
      temporalResource &&
      ReactPlayer.canPlay(temporalResource) &&
      !mediaURL.match(validMediaUrlRegExp)
    ) {
      setErrorFlag(false);
      setMediaURL(temporalResource);
    }
  }, [temporalResource, mediaURL]);

  const { compact, editing } = props;
  const dimensions: { width: string; height?: string } = { width: '100%' };
  if (compact) {
    dimensions.height = '100%';
  }

  if (errorFlag) {
    return (
      <div className="media-error">
        <Translate>This file type is not supported on media fields</Translate>
      </div>
    );
  }

  return (
    <div className={`video-container ${compact ? 'compact' : ''}`} ref={containerRef}>
      <div>
        {isLoading ? (
          <div className="loader">
            <Translate>Loading</Translate>
            <div className="bouncing-dots">
              <div className="dot" />
              <div className="dot" />
              <div className="dot" />
            </div>
          </div>
        ) : (
          <ReactPlayer
            className="react-player"
            playing={isVideoPlaying}
            config={{
              facebook: {
                attributes: {
                  'data-width': editing ? '' : containerRef.current?.clientWidth,
                  'data-height': editing ? '300' : containerRef.current?.clientHeight,
                },
              },
            }}
            ref={playerRef}
            url={mediaURL}
            {...dimensions}
            controls
            onPause={() => {
              setVideoPlaying(false);
            }}
            onPlay={() => {
              setVideoPlaying(true);
            }}
            onError={e => {
              if (e.target.error.message.search(/MEDIA_ELEMENT_ERROR/) === -1) {
                setErrorFlag(true);
              }
            }}
          />
        )}
      </div>
      {!editing && !isLoading && <div>{timeLinks(config.options.timelinks)}</div>}
      {editing && !isLoading && (
        <div className="timelinks-form">
          <button
            type="button"
            className="add-timelink"
            onClick={() => {
              const currentTime = playerRef.current?.getCurrentTime() as number;
              const hours = Math.floor(currentTime / 3600);
              const remainingSeconds = currentTime - hours * 3600;
              const minutes = Math.floor(remainingSeconds / 60);
              const seconds = Math.floor(remainingSeconds % 60);
              const timelink = {
                timeHours: hours < 10 ? `0${hours.toString()}` : hours.toString(),
                timeMinutes: minutes < 10 ? `0${minutes.toString()}` : minutes.toString(),
                timeSeconds: seconds < 10 ? `0${seconds.toString()}` : seconds.toString(),
                label: '',
              };
              appendTimelinkAndUpdateParent(timelink);
            }}
          >
            <Translate>Add timelink</Translate>
          </button>
          <h5>
            <Translate>Timelinks</Translate>
          </h5>
          {renderTimeLinksForm()}
        </div>
      )}
      <p className="print-view-alt">{config.url}</p>
    </div>
  );
};

export type { TimeLink, MarkdownMediaProps };
export default MarkdownMedia;