"use strict";

function App() {
  return (
    <div>
      <AppStateProvider>
        <Header />
        <Unauthed />
        <Threads />
        <Compose />
        <Footer />
      </AppStateProvider>
    </div>
  );
}

function Header() {
  const { user } = useAppState();
  return (
    <nav className="mb1 ml2 mt2 mr2 ltia-center-on-wide">
      <div className="ltia-logo">
        <a href="/" className="flex items-center nobright">
          <img alt="Logo" src="billie.svg" height="50" />
          <span
            className="ml1 text-primary"
            style={{ lineHeight: "92%", fontSize: "1.1rem" }}
          >
            <span>Last</span>
            <br />
            <span>tweet</span>
            <br />
            <span>
              <span
                style={{
                  fontSize: "60%",
                  verticalAlign: "top",
                  textTransform: "uppercase",
                }}
              >
                in
              </span>
              <span style={{ fontSize: "110%" }}>AWS</span>
            </span>
          </span>
        </a>
      </div>
      {user && (
        <ul className="mb0 mt0 ltia-navlist">
          <li className="ltia-logout">
            <a href={"/auth/logout"}>Log out @{user.username}</a>
          </li>
        </ul>
      )}
    </nav>
  );
}

function Threads() {
  const {
    user,
    threads,
    setThreads,
    activeThreadId,
    setActiveThreadId,
    threadsCollapsed,
    setThreadsCollapsed,
  } = useAppState();
  const [importing, setImporting] = React.useState(false);
  const [importingStatusUrl, setImportingStatusUrl] = React.useState("");
  const [importError, setImportError] = React.useState("");
  const threadsRef = React.useRef();

  function scrollToEnd() {
    if (!threadsRef.current) {
      return;
    }
    threadsRef.current.scrollLeft = 999999999;
    threadsRef.current.scrollTop = 999999999;
  }

  function setCollapsed(v) {
    if (!v) {
      window.requestAnimationFrame(scrollToEnd);
    }
    setThreadsCollapsed(v);
  }

  function handleImport() {
    setImportError("");
    getJson("/v1/get_tweet", { status_url: importingStatusUrl })
      .then((tweet) => {
        if (threads.some((t) => t.id === tweet.id)) {
          setImportError("Tweet is already imported");
          return;
        }
        setThreads([...threads, tweet]);
        setImporting(false);
        setActiveThreadId(tweet.id);
        setImportingStatusUrl("");
        setCollapsed(false);
      })
      .catch((r) => extractMessage(r).then(setImportError));
  }

  function handleRemoveTweet(tweet) {
    const newThreads = threads.filter((t) => t.id !== tweet.id);
    setThreads(newThreads);
    if (activeThreadId === tweet.id) {
      setActiveThreadId("");
    }
    if (newThreads.length === 0) {
      setCollapsed(true);
    }
  }

  function closeImporting() {
    setImporting(false);
    setImportError("");
  }

  React.useEffect(scrollToEnd, []);

  if (!user) {
    return null;
  }
  return (
    <section>
      <div className="flex flex-column ml2 w-100 ltia-center-on-wide">
        <h2
          className="mb2 cursor-pointer"
          onClick={() => setCollapsed(!threadsCollapsed)}
        >
          Threads {threadsCollapsed ? "⏬️" : "⏫️"}
        </h2>
        <div className="w-100">
          {importing ? (
            <div
              className="flex items-center mb2 w-100 max-width-card"
              style={{ height: 40 }}
            >
              <input
                type="url"
                id="threadimport"
                name="threadimport"
                placeholder="Full URL or Tweet ID"
                className="inline w-100"
                value={importingStatusUrl}
                onChange={(e) => setImportingStatusUrl(e.target.value)}
              />
              <div className="flex button-group h-100">
                <button type="submit" className="inline" onClick={handleImport}>
                  ✔
                </button>
                <button
                  type="cancel"
                  className="button-alt inline"
                  onClick={closeImporting}
                >
                  X
                </button>
              </div>
              <FormError error={importError} resetError={setImportError} margin="" />
            </div>
          ) : (
            <p>
              <a href="#" onClick={() => setImporting(true)}>
                Import
              </a>
            </p>
          )}
        </div>
      </div>
      <div
        className={clsx("ltia-threads-outer", threadsCollapsed && "collapsed")}
        ref={threadsRef}
      >
        <div className="ltia-threads-inner">
          {threads.map((tweet) => (
            <TweetCard
              key={tweet.id}
              tweet={tweet}
              onRemoveTweet={() => handleRemoveTweet(tweet)}
              onTweetRendered={scrollToEnd}
            />
          ))}
        </div>
      </div>
    </section>
  );
}

function TweetCard({ tweet, onRemoveTweet, onTweetRendered }) {
  const { id, text } = tweet;
  const { activeThreadId, setActiveThreadId, theme } = useAppState();
  const active = activeThreadId === id;
  const [htmlContent, setHtmlContent] = React.useState(
    window.twttr.txt.autoLink(text || "")
  );
  const elementId = "tweet-card-" + id;
  React.useEffect(() => {
    const existingTweetElement = document.querySelector(`#${elementId} > .twitter-tweet`);
    if (existingTweetElement) {
      existingTweetElement.remove();
    }
    window.twttr.widgets
      .createTweet(id, document.getElementById(elementId), {
        cards: "hidden",
        dnt: true,
        theme: theme,
      })
      .then(() => setHtmlContent(""))
      .then(onTweetRendered);
  }, [theme]);
  return (
    <div className="ltia-tweet">
      <blockquote className="twitter-tweet" id={elementId}>
        {htmlContent ? (
          <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
        ) : (
          <div />
        )}
      </blockquote>
      <div className="flex button-group">
        {active ? (
          <button className="flex-auto mt0" onClick={() => setActiveThreadId("")}>
            Continuing Thread
          </button>
        ) : (
          <button
            className="flex-auto mt0 button-alt"
            onClick={() => setActiveThreadId(id)}
          >
            Continue Thread
          </button>
        )}
        <button className="button-alt mt0" onClick={() => onRemoveTweet()}>
          X
        </button>
      </div>
    </div>
  );
}

function Compose() {
  const {
    user,
    activeThreadId,
    setActiveThreadId,
    threads,
    setThreads,
    setThreadsCollapsed,
  } = useAppState();
  const [text, setText] = React.useState("");
  const [quoteUrl, setQuoteUrl] = React.useState("");
  const [error, setError] = React.useState("");
  const [submitting, setSubmitting] = React.useState(false);
  const currentMediasRef = React.useRef([]);
  const [medias, setMediasInner] = React.useState(currentMediasRef.current);
  const [mediaAltRequired, setMediaAltRequired] = useLocalStorage("media-alt-req", true);
  const [autoGenAltText, setAutoGenAltText] = useLocalStorage("auto-gen-alt", true);
  const [ocrMode, setOcrMode] = useLocalStorage("ocr-mode", "off");

  const setMedias = React.useCallback((m) => {
    setMediasInner(m);
    currentMediasRef.current = m;
  });

  const activeThread = threads.find((t) => t.id === activeThreadId);

  const removeMedia = React.useCallback(
    (...toRemove) => setMedias(removeItems(medias, toRemove)),
    [medias]
  );

  const addFilesToMedia = React.useCallback(
    function addFilesToMedia(files) {
      if (files.length === 0) {
        return;
      }
      files = Array.from(files);
      const slotsRemaining = MAX_MEDIA - medias.length;
      if (slotsRemaining === 0) {
        setError(`Cannot upload file, max of 4`);
        return;
      }
      const toUpload = files.slice(0, slotsRemaining);
      if (files.length > slotsRemaining) {
        setError(`Only uploading ${toUpload.length} files since the max is ${MAX_MEDIA}`);
      }
      const newMedias = toUpload.map((f) =>
        fileToMedia(f, { autoCaption: autoGenAltText, ocrMode })
      );
      // When we start uploading, add all the medias, which will be in an upload state.
      setMedias([...medias, ...newMedias]);
      // Then as each promise resolves, we can replace or remove that instance.
      // Note we need to use currentMediasRef in case additional uploads were done.
      newMedias.forEach((m) => {
        m.uploadPromise
          .then((r) => {
            const newM = {
              ...m,
              uploadPromiseSettled: true,
              alt: m.alt || r.caption || "",
            };
            const withNewThis = currentMediasRef.current.map((cm) =>
              cm.key === m.key ? newM : cm
            );
            setMedias(withNewThis);
          })
          .catch((r) => {
            extractMessage(r).then((r) => setError(r));
            const withoutThis = currentMediasRef.current.filter((cm) => cm.key !== m.key);
            setMedias(withoutThis);
          });
      });
    },
    [removeMedia, medias, autoGenAltText]
  );

  const handleMediaChange = React.useCallback(
    (index, newMedia) => {
      const newMedias = [...medias];
      newMedias[index] = newMedia;
      setMedias(newMedias);
    },
    [medias]
  );

  function handleSend(e) {
    e.preventDefault();
    setSubmitting(true);
    setError("");
    const mediasCopy = [...medias];
    const mediaUploads = mediasCopy.map(({ uploadPromise }) => uploadPromise);
    Promise.all(mediaUploads)
      .then((apiResults) => apiResults.map(({ media_id }) => media_id))
      .then((mediaIds) => {
        const images = mediaIds.map((media_id, idx) => ({
          media_id,
          alt_text: mediasCopy[idx].alt,
        }));
        const replyTo = activeThreadId;
        return postJson("/v1/send_tweet", {
          text,
          reply_to_id: replyTo,
          images,
          quote_tweet_id: quoteUrl,
        }).then((tweet) => {
          setActiveThreadId(tweet.id);
          // Remove the responded-to thread if there is one, since this should replac eit.
          let newThreads = threads;
          if (replyTo) {
            newThreads = newThreads.filter((t) => t.id !== replyTo);
          }
          setThreads([...newThreads, tweet]);
          clear();
          setThreadsCollapsed(false);
        });
      })
      .catch((e) => extractMessage(e).then(setError))
      .finally(() => setSubmitting(false));
  }
  function clear() {
    setText("");
    setMedias([]);
    setQuoteUrl("");
  }
  function handleDrop(e) {
    e.preventDefault();
    setDragging(false);
    if (e.dataTransfer.files.length > 0) {
      addFilesToMedia(e.dataTransfer.files);
    }
  }
  const [dragging, setDragging] = React.useState(false);
  // Keep a counter on each 'enter' and decrement on 'leave',
  // because 'leave' is called when hovering over children.
  // So we only turn off dragging when we have left the parent.
  const dragCounterRef = React.useRef(0);
  function handleDragEnter(e) {
    e.preventDefault();
    e.stopPropagation();
    dragCounterRef.current += 1;
    setDragging(true);
  }
  function handleDragOver(e) {
    e.preventDefault();
    e.stopPropagation();
  }
  function handleDragLeave(e) {
    e.preventDefault();
    e.stopPropagation();
    dragCounterRef.current -= 1;
    if (dragCounterRef.current <= 0) {
      setDragging(false);
    }
  }
  if (!user) {
    return null;
  }
  const mediaError = invalidMediaCombo(medias);
  const realError = error || mediaError;
  const postingDisabled =
    !text ||
    submitting ||
    mediaError ||
    !mediaAltValid(medias, mediaAltRequired) ||
    !window.twttr.txt.parseTweet(text).valid;
  return (
    <section
      className="mt2 ml2 mr2 ltia-center-on-wide"
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <form className="ltia-filedrop-outer">
        <div className={clsx("ltia-filedrop-inner", dragging && "dragging")}>
          <h2>Compose</h2>
          {activeThreadId ? (
            <React.Fragment>
              <p>
                <span>Continuing Thread</span>
                <sup
                  className="cursor-pointer ml1 circle"
                  onClick={() => setActiveThreadId("")}
                >
                  X
                </sup>
              </p>
              {activeThread && (
                <p className="font-small text-muted ltia-preview">{activeThread.text}</p>
              )}
            </React.Fragment>
          ) : (
            <p>New Conversation</p>
          )}
          <label htmlFor="textarea1">Tweet:</label>
          <ComposeTextArea
            text={text}
            onTextChange={setText}
            addFilesToMedia={addFilesToMedia}
            onDrop={handleDrop}
            onSubmit={handleSend}
          />
          <input
            className="w-100 border-box"
            value={quoteUrl}
            placeholder="Status URL of Tweet to quote"
            onChange={(e) => setQuoteUrl(e.target.value)}
          />
          <FormError error={realError} resetError={setError} />
          <div className="flex justify-end">
            <TweetPreview text={text} />
            <Spacer />
            <div>
              <div className="flex">
                <CharacterCounter text={text} className="m1" />
                <div>
                  <button type="submit" onClick={handleSend} disabled={postingDisabled}>
                    {submitting ? "Tweeting..." : "Shitpost!"}
                  </button>
                </div>
              </div>
            </div>
          </div>
          <MediaManager
            className="mt1"
            medias={medias}
            altRequired={mediaAltRequired}
            onAltRequiredChange={(x) => setMediaAltRequired(x)}
            autoGenerateAltText={autoGenAltText}
            onAutoGenerateAltTextChange={(x) => setAutoGenAltText(x)}
            ocrMode={ocrMode}
            onOcrModeChange={(x) => setOcrMode(x)}
            addFilesToMedia={addFilesToMedia}
            removeMedia={removeMedia}
            onMediaChange={handleMediaChange}
          />
        </div>
      </form>
    </section>
  );
}

const EMPTY_ARRAY = [];

function ComposeTextArea({ text, onTextChange, addFilesToMedia, onDrop, onSubmit }) {
  const [userSearchResults, setUserSearchResultsInner] = React.useState(EMPTY_ARRAY);
  const [activeUserIdx, setActiveUserIdx] = React.useState(0);
  const textAreaRef = React.useRef();
  const lastRenderedTermRef = React.useRef("");
  const pendingPromiseRef = React.useRef(null);

  function setUserSearchResults(r) {
    setUserSearchResultsInner(r);
    setActiveUserIdx(0);
  }

  const showUserSearch = userSearchResults.length > 0;

  const updateForActiveTermChange = React.useMemo(
    // Whenever the active term changes (the caret moves, text changes, etc)
    // we need to recalculate the shown users.
    () =>
      debounced((term) => {
        if (!term) {
          if (lastRenderedTermRef.current) {
            // If there's no term, but the last render WAS for a term,
            // we need to empty out the search results, so it will close the window.
            lastRenderedTermRef.current = term;
            setUserSearchResults(EMPTY_ARRAY);
            // Cancel any pending promise.
            pendingPromiseRef.current = null;
          } else {
            // If there's no term, and it is already what we were doing, noop.
          }
          return;
        }
        // If there is a term, we can have 2 different scenarios:
        // - The current term and previously rendered terms are the same: maybe we just pressed an arrow. Noop.
        // - The current term and previously rendered terms are different: fetch and store.
        if (term === lastRenderedTermRef.current) {
          return;
        }
        lastRenderedTermRef.current = term;
        const searchPromise = getJson("/v1/search_users", { q: term, count: 10 })
          .then((r) => {
            if (pendingPromiseRef.current === searchPromise) {
              // If our promise has changed, we want to throw away the results of this query
              // and instead just use whatever promise is pending.
              setUserSearchResults(r);
            }
          })
          .catch((e) => console.error(e));
        pendingPromiseRef.current = searchPromise;
      }, 300),
    []
  );

  function handlePaste(e) {
    if (e.clipboardData.files.length > 0) {
      e.preventDefault();
      addFilesToMedia(e.clipboardData.files);
    }
  }

  function handleChange(e) {
    onTextChange(e.target.value);
  }

  function handleMouseOrKeyUp(e) {
    // Read the current text and cursor position,
    // and either issue a search, or close the user window.
    //
    // keyUp fires reliably on arrow press- keyPress is only for character-emitting events,
    // and keyDown does not give us reliable caret positions (it seems to be a stroke behind,
    // same for mouseDown).

    // Always use the caret position or start of selection, to mimic Twitter
    const cursorPos = e.target.selectionStart;
    const mentions = window.twttr.txt.extractMentionsWithIndices(text);
    const mentionContainingCursor = mentions.find(({ indices }) =>
      rangeContains(indices, cursorPos)
    );
    if (!mentionContainingCursor) {
      updateForActiveTermChange("");
      return;
    }
    updateForActiveTermChange(mentionContainingCursor.screenName);
  }

  function handleKeyDown(e) {
    // Submit on ctrl+enter
    const isSubmit = e.keyCode === 13 && e.metaKey;
    if (isSubmit) {
      e.preventDefault();
      onSubmit(e);
      return;
    }

    // When a key goes down, see if we need to modify the selected user.
    // This is more reliable/immediate than keyUp for taking action:
    // keyUp seems to get delayed arrow events. We don't need cursor position
    // for the mouse/arrow events (we do need it for enter), so keyDown is safe for here.
    if (!showUserSearch) {
      return;
    }
    const isUpArrow = e.keyCode === 38;
    const isDownArrow = e.keyCode === 40;
    const isEnter = e.keyCode === 13;
    if (isUpArrow) {
      let newIdx = activeUserIdx - 1;
      if (newIdx < 0) {
        newIdx = userSearchResults.length - 1;
      }
      setActiveUserIdx(newIdx);
      e.preventDefault();
    } else if (isDownArrow) {
      setActiveUserIdx((activeUserIdx + 1) % userSearchResults.length);
      e.preventDefault();
    } else if (isEnter) {
      const selectedUser = userSearchResults[activeUserIdx];
      if (!selectedUser) {
        return;
      }
      e.preventDefault();
      replaceMentionAtCaretWithString(selectedUser.screenName);
    }
  }

  function replaceMentionAtCaretWithString(newScreenName) {
    const textArea = textAreaRef.current;
    const cursorPos = textArea.selectionStart;
    const mentions = window.twttr.txt.extractMentionsWithIndices(text);
    const mention = mentions.find(({ indices }) => rangeContains(indices, cursorPos));
    if (!mention) {
      console.error(
        "Tried to replace screen name but no mention at current cursor pos",
        cursorPos,
        mentions
      );
    }
    const [start, end] = mention.indices;
    const leadingText = text.slice(0, start);
    const trailingText = text.slice(end, text.length);
    // The new name needs the leading @. It also MAY need a trailing ' ',
    // so we don't immediately trigger the search popup.
    // Note that the native client ALWAYS adds a space,
    // but this is sort of gross.
    // We only add a space if the trailing text does not already lead with a space,
    // so we don't keep adding spaces.
    // We do need to make an adjustment to the final cursor pos though,
    // so it's after the space.
    let replacementName = "@" + newScreenName;
    let cursorPosOffset;
    if (trailingText.startsWith(" ")) {
      // There's already a space; we need to offset the cursor to after it.
      cursorPosOffset = 1;
    } else {
      // Add a space, and the cursor will go after it.
      replacementName += " ";
      cursorPosOffset = 0;
    }
    const newText = leadingText + replacementName + trailingText;
    onTextChange(newText);
    setUserSearchResults(EMPTY_ARRAY);
    const newCursorPos = start + replacementName.length;
    // We must set the value explicitly here, so the selection can modify properly.
    // Otherwise selectionStart is invalid because value hasn't updated yet.
    textArea.value = newText;
    textArea.selectionStart = textArea.selectionEnd = newCursorPos + cursorPosOffset;
    // Make sure we're focused, in case this was a mouse click on the list.
    textArea.focus();
  }

  return (
    <div className="relative">
      <textarea
        ref={textAreaRef}
        rows="5"
        className="w-100 border-box"
        value={text}
        placeholder="Enter your shitpost here"
        onChange={handleChange}
        onPaste={handlePaste}
        onKeyDown={handleKeyDown}
        onKeyUp={handleMouseOrKeyUp}
        onMouseUp={handleMouseOrKeyUp}
        onDrop={onDrop}
      />
      {userSearchResults.length > 0 && (
        <div className="ltia-user-search-box">
          {userSearchResults.map(({ name, image, screenName }, idx) => (
            <div
              key={screenName}
              className={clsx("ltia-user-search-item", idx === activeUserIdx && "active")}
              onClick={() => replaceMentionAtCaretWithString(screenName)}
            >
              <img
                alt={`${name} profile`}
                src={image}
                width={40}
                height={40}
                className="circle"
              />
              <div className="flex flex-column ml2">
                <div className="bold mb0">{name}</div>
                <div className="text-secondary">@{screenName}</div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function MediaManager({
  className,
  medias,
  addFilesToMedia,
  removeMedia,
  onMediaChange,
  altRequired,
  onAltRequiredChange,
  autoGenerateAltText,
  onAutoGenerateAltTextChange,
  ocrMode,
  onOcrModeChange,
}) {
  const uploadRef = React.useRef();
  const { debugMode, theme } = useAppState();

  function handleAdd(e) {
    e.preventDefault();
    uploadRef.current.click();
  }
  function handleRemove(e, m) {
    e.preventDefault();
    // Allow the same image to be uploaded again.
    uploadRef.current.value = "";
    removeMedia(m);
  }
  function handleChoice(e) {
    e.preventDefault();
    addFilesToMedia(e.target.files);
  }

  return (
    <div className={clsx("flex flex-column", className)}>
      {medias.length < MAX_MEDIA && (
        <button className="ltia-media-add" onClick={handleAdd}>
          +
        </button>
      )}
      {medias.map((m, index) => {
        let el = (
          <UploadPreview
            key={m.key}
            altRequired={altRequired}
            {...m}
            onClick={(e) => handleRemove(e, m)}
            onAltChange={(alt) => onMediaChange(index, { ...m, alt })}
          />
        );
        if (debugMode) {
          el = (
            <React.Fragment key={m.key}>
              <div>
                {theme === DARK ? "🌃" : "🌇"} {m.file.name}: {m.file.type},{" "}
                {b2kb(m.file.size, 2)}kb
              </div>
              {el}
            </React.Fragment>
          );
        }
        return el;
      })}
      <div className="mt2 flex items-start flex-column">
        <div>
          <input
            type="checkbox"
            id="mediaAltRequired"
            name="mediaAltRequired"
            checked={altRequired}
            className="m0"
            onChange={(e) => onAltRequiredChange(e.target.checked)}
          />
          <label htmlFor="mediaAltRequired" className="m0 ml1">
            Require Alt Text
          </label>
        </div>
        <div>
          <input
            type="checkbox"
            id="autoGenAltText"
            name="autoGenAltText"
            checked={autoGenerateAltText}
            className="m0"
            onChange={(e) => onAutoGenerateAltTextChange(e.target.checked)}
          />
          <label htmlFor="autoGenAltText" className="m0 ml1">
            Auto-Generate Alt Text
          </label>
        </div>
      </div>
      <div className="flex items-center">
        <label>OCR Mode:</label>
        <div className="button-group ml2">
          <button
            className={clsx("button-small", ocrMode !== "off" && "button-alt")}
            onClick={preventDefault(() => onOcrModeChange("off"))}
          >
            Off
          </button>
          <button
            className={clsx("button-small", ocrMode !== "always" && "button-alt")}
            onClick={preventDefault(() => onOcrModeChange("always"))}
          >
            Always
          </button>
          <button
            className={clsx("button-small", ocrMode !== "guess" && "button-alt")}
            onClick={preventDefault(() => onOcrModeChange("guess"))}
          >
            Guess
          </button>
        </div>
      </div>
      <input
        type="file"
        multiple
        accept=".png,image/png,.jpeg,.jpg,image/jpeg,.gif,image/gif"
        style={{ position: "absolute", left: -2000 }}
        ref={uploadRef}
        onChange={handleChoice}
      />
    </div>
  );
}

const MAX_MEDIA = 4;

function UploadPreview({
  alt,
  file,
  uploadPromise,
  uploadPromiseSettled,
  altRequired,
  onAltChange,
  onClick,
}) {
  const canvasRef = React.useRef();
  const [processing, setProcessing] = React.useState(true);
  React.useEffect(() => {
    window.imageCompression(file, { maxWidthOrHeight: 55 }).then((compressed) => {
      if (!canvasRef.current) {
        // The canvas can be removed from the DOM if the file is rejected
        return;
      }
      const ctx = canvasRef.current.getContext("2d");
      const img = new Image();
      img.onload = function () {
        ctx.drawImage(img, 0, 0);
      };
      img.src = URL.createObjectURL(compressed);
    });
  }, [file]);
  React.useEffect(() => {
    uploadPromise.then(() => setProcessing(false));
  }, [uploadPromise]);
  const altId = uniqueId();

  return (
    <div className="ltia-media-img flex items-center justify-start">
      <div className={clsx("lds-ring absolute", !processing && "display-none")}>
        <div />
        <div />
        <div />
        <div />
      </div>
      <canvas ref={canvasRef} width={55} height={55} onClick={onClick} />
      <AltTextInput
        id={altId}
        value={alt}
        required={altRequired}
        disabled={!uploadPromiseSettled}
        className="ml2 mb0 mr1 flex1"
        onTextChange={(v) => onAltChange(v)}
      />
    </div>
  );
}

const tweetCharLimit = 280;

function AltTextInput({ id, value, required, disabled, className, onTextChange }) {
  function handleChange(e) {
    let t = e.target.value;
    t = t.slice(0, MAX_ALT_TEXT_LENGTH);
    onTextChange(t);
  }
  let errorCls, warningText;
  if (required && !value) {
    errorCls = "invalid";
  } else if (value.length > ALT_TEXT_WARN_LENGTH) {
    errorCls = "warning";
    if (value.length >= MAX_ALT_TEXT_LENGTH) {
      warningText = `Reached the max alt text length of ${MAX_ALT_TEXT_LENGTH} characters`;
    } else {
      warningText = `Approaching max alt text length (${value.length}/${MAX_ALT_TEXT_LENGTH})`;
    }
  }

  return (
    <React.Fragment>
      <label htmlFor={id} className="ml2">
        Alt{required && <span className="text-error">*</span>}:
      </label>
      <div className={clsx("flex flex-column py1", className)}>
        <AutoSwitchTextInput
          id={id}
          value={value}
          type="text"
          className={clsx("flex1", className, errorCls)}
          disabled={disabled}
          onChange={handleChange}
        />
        {warningText && (
          <div className="font-small color-warning self-end mr1 mt1 right-align">
            {warningText}
          </div>
        )}
      </div>
    </React.Fragment>
  );
}

const MAX_ALT_TEXT_LENGTH = 1000;
const ALT_TEXT_WARN_LENGTH = Math.ceil(MAX_ALT_TEXT_LENGTH * 0.9);
// With padding/margin, I can fix 43 chars when the width is 410.
// If we modify sizing, modify this ratio.
const AUTOSWITCH_CHAR_WIDTH = Math.floor(410.0 / 43);

function AutoSwitchTextInput({ value, className, onChange, ...rest }) {
  const [InputComp, setInputComp] = React.useState("input");
  const [inputProps, setInputProps] = React.useState({});
  const transferringFields = React.useRef(null);
  const inputRef = React.useRef();

  function handleChange(e) {
    const textwidth = e.target.value.length * AUTOSWITCH_CHAR_WIDTH;
    if (InputComp === "input") {
      // Do we need to switch to textarea?
      if (textwidth > e.target.offsetWidth) {
        setInputComp("textarea");
        setInputProps({ rows: 5 });
        transferringFields.current = {
          selectionStart: e.target.selectionStart,
        };
      }
    } else {
      // Do we switch back to a text input?
      if (textwidth < e.target.offsetWidth) {
        setInputComp("input");
        setInputProps({});
        transferringFields.current = {
          selectionStart: e.target.selectionStart,
        };
      }
    }
    if (onChange) {
      onChange(e);
    }
  }
  const handleRef = React.useCallback((n) => {
    inputRef.current = n;
    if (!n) {
      return;
    }
    if (transferringFields.current) {
      n.focus();
      n.setSelectionRange(
        transferringFields.current.selectionStart,
        transferringFields.current.selectionStart
      );
      transferringFields.current = null;
    }
  }, []);

  return (
    <InputComp
      ref={handleRef}
      type="text"
      value={value}
      onChange={handleChange}
      {...inputProps}
      className={clsx("ltia-alttext", className)}
      {...rest}
    />
  );
}

function TweetPreview({ text }) {
  const { user } = useAppState();
  if (!user) {
    return null;
  }
  const tweetHtml = window.twttr.txt.autoLink(text);
  const withBrs = tweetHtml.replaceAll("\n", "<br />") || "<br />";
  return (
    <div className="ltia-preview">
      <div>
        <b>{user.name}</b>
        <span className="text-secondary ml1">@{user.username}</span>
      </div>
      <div>
        <div className="break-word" dangerouslySetInnerHTML={{ __html: withBrs }} />
      </div>
    </div>
  );
}

function CharacterCounter({ text, className }) {
  const { weightedLength, valid } = window.twttr.txt.parseTweet(text);
  const remainingChars = tweetCharLimit - weightedLength;
  let showFont, textColor, arcSize, arcColor;
  const maxDeg = 359.9999; // 360 is broken and I don't understand the math
  let arcDeg = (weightedLength / tweetCharLimit) * maxDeg;
  const largeArcSize = 36;
  if (text && !valid && remainingChars < 0) {
    showFont = true;
    textColor = "var(--color-error)";
    arcSize = largeArcSize;
    arcColor = "var(--color-error)";
    arcDeg = maxDeg;
  } else if (remainingChars < 20) {
    showFont = true;
    textColor = "var(--color-text-muted)";
    arcSize = largeArcSize;
    arcColor = "var(--color-warning)";
  } else {
    showFont = false;
    textColor = "var(--color-text-muted)";
    arcSize = 20;
    arcColor = "var(--color-twitter)";
  }
  const arcStroke = 3;
  const charStyle = {
    display: showFont ? null : "none",
    color: textColor,
  };
  const wrapperSize = `calc(${arcSize}px + (${arcStroke}px * 2))`;
  const wrapperStyle = {
    position: "relative",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    width: wrapperSize,
    height: wrapperSize,
    overflow: "hidden",
  };
  const arcRad = arcSize / 2;
  return (
    <div
      className={clsx("flex justify-center items-center", className)}
      style={{
        minWidth: `calc(${largeArcSize}px + (${arcStroke}px * 2))`,
      }}
    >
      <div style={wrapperStyle}>
        <svg style={{ position: "absolute", top: 0, left: 0 }}>
          <path
            fill="none"
            stroke="var(--color-text-light)"
            strokeWidth={arcStroke}
            d={describeArc(arcRad + arcStroke, arcRad + arcStroke, arcRad, 0, maxDeg)}
          />
        </svg>
        <svg style={{ position: "absolute", top: 0, left: 0 }}>
          <path
            fill="none"
            stroke={arcColor}
            strokeWidth={arcStroke}
            d={describeArc(arcRad + arcStroke, arcRad + arcStroke, arcRad, 0, arcDeg)}
          />
        </svg>
        <div style={charStyle}>{remainingChars}</div>
      </div>
    </div>
  );
}

// From this outstanding stack overflow:
// https://stackoverflow.com/a/18473154
const describeArc = (function () {
  function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
    const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
    return {
      x: centerX + radius * Math.cos(angleInRadians),
      y: centerY + radius * Math.sin(angleInRadians),
    };
  }
  function describeArc(x, y, radius, startAngle, endAngle) {
    const start = polarToCartesian(x, y, radius, endAngle);
    const end = polarToCartesian(x, y, radius, startAngle);
    const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
    const d = [
      "M",
      start.x,
      start.y,
      "A",
      radius,
      radius,
      0,
      largeArcFlag,
      0,
      end.x,
      end.y,
    ].join(" ");
    return d;
  }
  return describeArc;
})();

function Footer() {
  const { debugMode, setDebugMode } = useAppState();
  // const [showCopy]
  function handleDebugMode(e) {
    e.preventDefault();
    setDebugMode(!debugMode);
  }
  function handleCopyConsoleLogs(e) {
    e.preventDefault();
    navigator.clipboard.writeText(document.querySelector("#debug-logs").innerText);
    console.debug("Copied debug logs");
    // console.log("Copied debug logs");
    // console.warn("Copied debug logs");
    // console.error("Copied debug logs");
  }
  return (
    <footer>
      <hr className="mb2" />
      <div className="flex flex-column">
        <p className="self-start">
          <small>
            A web app to make shitposting easier, served from the fires of us-east-2. Made
            with love and snark by{" "}
            <a href="https://duckbillgroup.com">The Duckbill Group</a>.
          </small>
        </p>
        <div className="self-end flex justify-between items-center w-100">
          <button
            onClick={handleDebugMode}
            className="color-primary link-button"
            style={{ opacity: debugMode ? 1 : 0.02 }}
          >
            {debugMode ? "Disable" : "Enable"} Debug Mode
          </button>
          <LightmodeDarkmodeToggle />
        </div>
      </div>
      {debugMode && (
        <React.Fragment>
          <p>
            Console Logs:{" "}
            <button className="link-button" onClick={handleCopyConsoleLogs}>
              {"📋"}
            </button>
          </p>
          <div
            id="debug-logs"
            className="w-100"
            contentEditable={false}
            style={{ height: CONSOLE_HEIGHT }}
          />
        </React.Fragment>
      )}
    </footer>
  );
}

function LightmodeDarkmodeToggle({ className }) {
  const { theme, setTheme } = useAppState();
  React.useEffect(() => {
    if (theme === LIGHT) {
      document.documentElement.classList.remove(DARK);
    } else {
      document.documentElement.classList.add(DARK);
    }
  }, [theme]);
  const content = theme === DARK ? "🌒" : "☀️";
  function handleClick(e) {
    e.preventDefault();
    setTheme(theme === DARK ? LIGHT : DARK);
  }
  return (
    <button className={clsx("ltia-lightdark", className)} onClick={handleClick}>
      {content}
    </button>
  );
}

function Unauthed() {
  const { user } = useAppState();
  if (user) {
    return null;
  }
  return (
    <section className="flex flex-column items-center">
      <h2 className="mb2">Sign in with Twitter</h2>
      <a href={"/auth/twitter"}>
        <b>Get Started!</b>
      </a>
    </section>
  );
}

function Spacer() {
  return <div className="flex-auto" />;
}
const AppStateContext = React.createContext();
const useAppState = () => React.useContext(AppStateContext);
function AppStateProvider({ children }) {
  const [activeThreadId, setActiveThreadId] = useLocalStorage(
    "ltia-active-thread-url",
    ""
  );
  const [user, setUser] = useLocalStorage("ltia-user", null);
  React.useEffect(() => {
    getJson("/v1/me")
      .catch((r) => {
        setUser(null);
        localStorage.clear();
        throw r;
      })
      .then((d) => setUser(d.data))
      .catch((r) => console.error(r));
  }, []);
  const [threads, setThreads] = useLocalStorage("ltia-threads", []);
  const [threadsCollapsed, setThreadsCollapsed] = useLocalStorage("ltia-collapsed", true);
  const [debugMode, setDebugMode] = useLocalStorage("ltia-debug-mode", false);
  const [theme, setTheme] = useLocalStorage(
    "ltia-theme",
    window.matchMedia("(prefers-color-scheme: dark)").matches ? DARK : LIGHT
  );

  return (
    <AppStateContext.Provider
      value={{
        activeThreadId,
        setActiveThreadId,
        user,
        setUser,
        threads,
        setThreads,
        threadsCollapsed,
        setThreadsCollapsed,
        theme,
        setTheme,
        debugMode,
        setDebugMode,
      }}
    >
      {children}
    </AppStateContext.Provider>
  );
}

const DARK = "dark";
const LIGHT = "light";

/**
 * @typedef Media
 * @property {string} key
 * @property {File} file
 * @property {string} name
 * @property {string} type
 * @property {string} alt
 * @property {Promise<object>} uploadPromise Created in fileToMedia when the upload stats.
 *   uploadPromise.settled is an additional field set to 'true' when
 *   the upload promise settles.
 *
 * @property {boolean} uploadPromiseSettled Is set to true when uploadPromise settles.
 *   Note that this property is 'externally managed' by
 *   the code that awaits the upload promises.
 */

/**
 * Given a File upload, return a Media that is in the process of uploading.
 * @param {File} file File to upload. Should have name and type set.
 * @param {boolean} autoCaption If true, generate captions using a CV backend.
 * @param {string} ocrMode 'off': no OCR,
 *   'always': always use OCR,
 *   'guess': use OCR if image has a set of tags that indicate it's probably text.
 * @returns {Media}
 */
function fileToMedia(file, { autoCaption, ocrMode }) {
  const uploadPromise = (function () {
    if (!canAttachImage(file)) {
      return Promise.reject(`File type ${file.type} cannot be attached to a tweet.`);
    }
    let compressor;
    if (canCompressImage(file)) {
      // 4 MB is the max Azure image size, also give it some breating room
      const maxFileSize = mb2b(4) - kb2b(10);
      compressor = compressUntilUnderSize(file, maxFileSize);
    } else {
      compressor = Promise.resolve(file);
    }
    return compressor.then((blob) => {
      if (blob.size > MAX_UPLOAD_BYTES) {
        return Promise.reject(`Max file size of ${b2mb(MAX_UPLOAD_BYTES, 1)}mb`);
      }
      const formData = new FormData();
      formData.append("image", blob);
      formData.append("name", file.name);
      formData.append("type", file.type);
      formData.append("autocaption", autoCaption ? "1" : "");
      formData.append("ocrmode", ocrMode);
      return fetch("/v1/upload_media", { method: "POST", body: formData })
        .then(rejectStatus)
        .then((r) => r.json());
    });
  })();
  return {
    key: file.name + uniqueId(),
    file,
    name: file.name,
    type: file.type,
    alt: "",
    uploadPromise,
    uploadPromiseSettled: false,
  };
}

const MAX_UPLOAD_BYTES = mb2b(5) - kb2b(10);

function canCompressImage(file) {
  return file.type === "image/png" || file.type === "image/jpeg";
}

function canAttachImage(file) {
  return (
    file.type === "image/png" || file.type === "image/jpeg" || file.type === "image/gif"
  );
}

function invalidMediaCombo(medias) {
  if (medias.length <= 1) {
    return "";
  }
  const hasgif = medias.some((m) => m.type === "image/gif");
  if (hasgif) {
    return "GIFs cannot be used with multiple images.";
  }
  return "";
}

/**
 * @param {Array<Media>|null} medias
 * @param {boolean} required
 * @returns {boolean}
 */
function mediaAltValid(medias, required) {
  if (!required) {
    return true;
  }
  if (!medias || medias.length === 0) {
    return true;
  }
  return medias.every(({ alt }) => alt);
}

function FormError({ error, resetError, margin }) {
  if (!error) {
    return null;
  }
  const marginCls = margin === undefined ? "mb1" : margin;
  if (typeof error !== "string") {
    error = JSON.stringify(error);
  }
  return (
    <div
      className={clsx(
        "text-light bg-error rounded p1 bold border-box cursor-pointer flex justify-between",
        marginCls
      )}
      onClick={() => resetError("")}
    >
      <div className="ltia-form-error" title={error}>
        {error}
      </div>
      <div>ⓧ</div>
    </div>
  );
}

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = React.useState(
    (() => {
      try {
        const item = window.localStorage.getItem(key);
        return item ? JSON.parse(item) : initialValue;
      } catch (error) {
        console.error(error);
        return initialValue;
      }
    })()
  );
  const setValue = React.useCallback(
    (value) => {
      try {
        setStoredValue(value);
        window.localStorage.setItem(key, JSON.stringify(value));
      } catch (error) {
        console.error(error);
      }
    },
    [storedValue]
  );
  return [storedValue, setValue];
}

const clsx = (function () {
  function toVal(mix) {
    let k,
      y,
      str = "";

    if (typeof mix === "string" || typeof mix === "number") {
      str += mix;
    } else if (typeof mix === "object") {
      if (Array.isArray(mix)) {
        for (k = 0; k < mix.length; k++) {
          if (mix[k]) {
            y = toVal(mix[k]);
            if (y) {
              str && (str += " ");
              str += y;
            }
          }
        }
      } else {
        for (k in mix) {
          // noinspection JSUnfilteredForInLoop
          if (mix[k]) {
            str && (str += " ");
            str += k;
          }
        }
      }
    }

    return str;
  }

  return function () {
    let i = 0,
      tmp,
      x,
      str = "";
    while (i < arguments.length) {
      tmp = arguments[i++];
      if (tmp) {
        x = toVal(tmp);
        if (x) {
          str && (str += " ");
          str += x;
        }
      }
    }
    return str;
  };
})();

function postJson(url, data) {
  return fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data), // body data type must match "Content-Type" header
  })
    .then(injectChaos)
    .then(rejectStatus)
    .then((r) => r.json());
}

function getJson(url, params) {
  const urlo = new URL(url, window.location.origin);
  if (params) {
    Object.keys(params).forEach((key) => urlo.searchParams.append(key, params[key]));
  }
  return fetch(urlo.toString())
    .then(injectChaos)
    .then(rejectStatus)
    .then((r) => r.json());
}

function rejectStatus(resp) {
  if (resp.status >= 400) {
    return Promise.reject(resp);
  }
  return resp;
}

function extractMessage(resp) {
  if (resp instanceof Response) {
    return resp
      .json()
      .then((data) => {
        if (data.message) {
          return data.message;
        }
        return Promise.reject("no message");
      })
      .catch(() => resp.text());
  }
  let msg;
  if (resp instanceof Object) {
    msg = resp.message;
  }
  msg = msg || "" + resp;
  return Promise.resolve(msg);
}

/**
 * @param {Array<T>} array
 * @param {Array<T>} toRemove
 * @param {function(T, T): boolean=} predicate
 * @return {*}
 */
function removeItems(array, toRemove, predicate) {
  if (toRemove.length === 0) {
    return array;
  }
  predicate = predicate || ((x, y) => x === y);
  const toKeep = array.filter((m) => {
    const isInToRemove = toRemove.some((cand) => predicate(m, cand));
    return !isInToRemove;
  });
  return toKeep;
}

function debounced(f, wait) {
  let lastArgs = [];
  let handle = 0;
  function debouncedFunc() {
    lastArgs = arguments;
    handle && clearTimeout(handle);
    handle = setTimeout(() => f.apply(null, lastArgs), wait || 1000);
  }
  return debouncedFunc;
}

function preventDefault(cb) {
  return (e) => {
    e.preventDefault();
    cb(e);
  };
}

function rangeContains(range, value) {
  const [lower, upper] = range;
  return value >= lower && value <= upper;
}

function delay(wait) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(), wait);
  });
}

function uniqueId() {
  _uniqueId++;
  return `uid-${_uniqueId}`;
}

let _uniqueId = 0;

const CHAOS_MODE = false;

function injectChaos(value) {
  if (CHAOS_MODE) {
    return delay(Math.random() * 1500).then(() => value);
  }
  return Promise.resolve(value);
}

function compressUntilUnderSize(file, maxByteSize) {
  if (maxByteSize < 0) {
    return Promise.reject(
      "Could not compress image to below its expectes size, something went very wrong."
    );
  }
  return window
    .imageCompression(file, {
      maxSizeMB: b2mb(maxByteSize),
    })
    .then((blob) => {
      if (blob.size <= maxByteSize) {
        console.debug(
          `compressed ${file.name} (${b2kb(file.size, 2)}kb) to ${b2kb(blob.size, 2)}kb`
        );
        return blob;
      }
      console.warn(
        `could not compress ${file.name} (${b2kb(file.size, 2)}kb) to less than ${b2kb(
          maxByteSize,
          2
        )}kb, shrinking 500kb`
      );
      return compressUntilUnderSize(file, maxByteSize - kb2b(500));
    });
}

function b2kb(n, r) {
  return _roundif(n / 1024, r);
}

function kb2b(b, r) {
  return _roundif(b * 1024, r);
}

function mb2b(n, r) {
  return _roundif(n * 1024 * 1024, r);
}

function b2mb(n, r) {
  return _roundif(n / 1024 / 1024, r);
}

function _roundif(x, r) {
  if (isNaN(Number(r))) {
    return x;
  }
  return x.toFixed(r);
}

function patchLog(func) {
  const orig = console[func];
  function doLog(...args) {
    const arr = [...args];
    orig.apply(null, [...arr]);
    const debugLogsDiv = document.querySelector("#debug-logs");
    if (debugLogsDiv) {
      const isAtBottom =
        debugLogsDiv.scrollTop + CONSOLE_HEIGHT >= debugLogsDiv.scrollHeight;
      const txt = arr.join(", ");
      const line = `<div class="console-line console-line-${func}"">${txt}</div>`;
      debugLogsDiv.innerHTML += line;
      if (isAtBottom) {
        debugLogsDiv.scrollTop = debugLogsDiv.scrollHeight;
      }
    }
  }
  console[func] = doLog;
}
const CONSOLE_HEIGHT = 300;
const _origLog = console.log;
patchLog("debug");
patchLog("log");
patchLog("info");
patchLog("warn");
patchLog("error");

const domContainer = document.querySelector("#root");
ReactDOM.render(<App />, domContainer);
