Browse Source

[Proposal] Make able to write React in Typescript (#16210)

Co-authored-by: berlysia <berlysia@gmail.com>
Co-authored-by: fusagiko / takayamaki <takayamaki@users.noreply.github.com>
fusagiko / takayamaki 1 year ago
parent
commit
4520e6473a

+ 11 - 8
.eslintrc.js

@@ -20,13 +20,14 @@ module.exports = {
     ATTACHMENT_HOST: false,
   },
 
-  parser: '@babel/eslint-parser',
+  parser: '@typescript-eslint/parser',
 
   plugins: [
     'react',
     'jsx-a11y',
     'import',
     'promise',
+    '@typescript-eslint',
   ],
 
   parserOptions: {
@@ -41,14 +42,13 @@ module.exports = {
       presets: ['@babel/react', '@babel/env'],
     },
   },
-
+  extends: [
+    'plugin:import/typescript',
+  ],
   settings: {
     react: {
       version: 'detect',
     },
-    'import/extensions': [
-      '.js', '.jsx',
-    ],
     'import/ignore': [
       'node_modules',
       '\\.(css|scss|json)$',
@@ -56,7 +56,7 @@ module.exports = {
     'import/resolver': {
       node: {
         paths: ['app/javascript'],
-        extensions: ['.js', '.jsx'],
+        extensions: ['.js', '.jsx', '.ts', '.tsx'],
       },
     },
   },
@@ -97,7 +97,8 @@ module.exports = {
     'no-self-assign': 'off',
     'no-trailing-spaces': 'warn',
     'no-unused-expressions': 'error',
-    'no-unused-vars': [
+    'no-unused-vars': 'off',
+    '@typescript-eslint/no-unused-vars': [
       'error',
       {
         vars: 'all',
@@ -116,7 +117,7 @@ module.exports = {
     semi: 'error',
     'valid-typeof': 'error',
 
-    'react/jsx-filename-extension': ['error', { 'allow': 'as-needed' }],
+    'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }],
     'react/jsx-boolean-value': 'error',
     'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
     'react/jsx-curly-spacing': 'error',
@@ -192,6 +193,8 @@ module.exports = {
       {
         js: 'never',
         jsx: 'never',
+        ts: 'never',
+        tsx: 'never',
       },
     ],
     'import/newline-after-import': 'error',

+ 17 - 0
app/javascript/hooks/useHovering.ts

@@ -0,0 +1,17 @@
+import { useCallback, useState } from 'react';
+
+export const useHovering = (animate?: boolean) => {
+  const [hovering, setHovering] = useState<boolean>(animate ?? false);
+
+  const handleMouseEnter = useCallback(() => {
+    if (animate) return;
+    setHovering(true);
+  }, [animate]);
+
+  const handleMouseLeave = useCallback(() => {
+    if (animate) return;
+    setHovering(false);
+  }, [animate]);
+
+  return { hovering, handleMouseEnter, handleMouseLeave };
+};

+ 1 - 0
app/javascript/mastodon/actions/picture_in_picture.js

@@ -23,6 +23,7 @@ export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
  * @return {object}
  */
 export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
+  // @ts-expect-error
   return (dispatch, getState) => {
     // Do not open a player for a toot that does not exist
     if (getState().hasIn(['statuses', statusId])) {

+ 16 - 4
app/javascript/mastodon/actions/streaming.js

@@ -46,6 +46,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
   connectStream(channelName, params, (dispatch, getState) => {
     const locale = getState().getIn(['meta', 'locale']);
 
+    // @ts-expect-error
     let pollingId;
 
     /**
@@ -61,9 +62,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
       onConnect() {
         dispatch(connectTimeline(timelineId));
 
+        // @ts-expect-error
         if (pollingId) {
-          clearTimeout(pollingId);
-          pollingId = null;
+          // @ts-ignore
+          clearTimeout(pollingId); pollingId = null;
         }
 
         if (options.fillGaps) {
@@ -75,31 +77,38 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         dispatch(disconnectTimeline(timelineId));
 
         if (options.fallback) {
+          // @ts-expect-error
           pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000));
         }
       },
 
-      onReceive (data) {
-        switch(data.event) {
+      onReceive(data) {
+        switch (data.event) {
         case 'update':
+          // @ts-expect-error
           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
           break;
         case 'status.update':
+          // @ts-expect-error
           dispatch(updateStatus(JSON.parse(data.payload)));
           break;
         case 'delete':
           dispatch(deleteFromTimelines(data.payload));
           break;
         case 'notification':
+          // @ts-expect-error
           dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
           break;
         case 'conversation':
+          // @ts-expect-error
           dispatch(updateConversations(JSON.parse(data.payload)));
           break;
         case 'announcement':
+          // @ts-expect-error
           dispatch(updateAnnouncements(JSON.parse(data.payload)));
           break;
         case 'announcement.reaction':
+          // @ts-expect-error
           dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
           break;
         case 'announcement.delete':
@@ -115,7 +124,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
  * @param {function(): void} done
  */
 const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  // @ts-expect-error
   dispatch(expandHomeTimeline({}, () =>
+    // @ts-expect-error
     dispatch(expandNotifications({}, () =>
       dispatch(fetchAnnouncements(done))))));
 };
@@ -124,6 +135,7 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
  * @return {function(): void}
  */
 export const connectUserStream = () =>
+  // @ts-expect-error
   connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
 
 /**

+ 2 - 2
app/javascript/mastodon/api.js

@@ -36,7 +36,7 @@ const setCSRFHeader = () => {
 ready(setCSRFHeader);
 
 /**
- * @param {() => import('immutable').Map} getState
+ * @param {() => import('immutable').Map<string,any>} getState
  * @returns {import('axios').RawAxiosRequestHeaders}
  */
 const authorizationHeaderFromState = getState => {
@@ -52,7 +52,7 @@ const authorizationHeaderFromState = getState => {
 };
 
 /**
- * @param {() => import('immutable').Map} getState
+ * @param {() => import('immutable').Map<string,any>} getState
  * @returns {import('axios').AxiosInstance}
  */
 export default function api(getState) {

+ 0 - 62
app/javascript/mastodon/components/avatar.jsx

@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from '../initial_state';
-import classNames from 'classnames';
-
-export default class Avatar extends React.PureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.map,
-    size: PropTypes.number.isRequired,
-    style: PropTypes.object,
-    inline: PropTypes.bool,
-    animate: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    animate: autoPlayGif,
-    size: 20,
-    inline: false,
-  };
-
-  state = {
-    hovering: false,
-  };
-
-  handleMouseEnter = () => {
-    if (this.props.animate) return;
-    this.setState({ hovering: true });
-  };
-
-  handleMouseLeave = () => {
-    if (this.props.animate) return;
-    this.setState({ hovering: false });
-  };
-
-  render () {
-    const { account, size, animate, inline } = this.props;
-    const { hovering } = this.state;
-
-    const style = {
-      ...this.props.style,
-      width: `${size}px`,
-      height: `${size}px`,
-    };
-
-    let src;
-
-    if (hovering || animate) {
-      src = account?.get('avatar');
-    } else {
-      src = account?.get('avatar_static');
-    }
-
-    return (
-      <div className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style}>
-        {src && <img src={src} alt={account?.get('acct')} />}
-      </div>
-    );
-  }
-
-}

+ 40 - 0
app/javascript/mastodon/components/avatar.tsx

@@ -0,0 +1,40 @@
+import * as React from 'react';
+import classNames from 'classnames';
+import { autoPlayGif } from '../initial_state';
+import { useHovering } from '../../hooks/useHovering';
+import type { Account } from '../../types/resources';
+
+type Props = {
+  account: Account;
+  size: number;
+  style?: React.CSSProperties;
+  inline?: boolean;
+  animate?: boolean;
+}
+
+export const Avatar: React.FC<Props> = ({
+  account,
+  animate = autoPlayGif,
+  size = 20,
+  inline = false,
+  style: styleFromParent,
+}) => {
+
+  const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
+
+  const style = {
+    ...styleFromParent,
+    width: `${size}px`,
+    height: `${size}px`,
+  };
+
+  const src = (hovering || animate) ? account?.get('avatar') : account?.get('avatar_static');
+
+  return (
+    <div className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={style}>
+      {src && <img src={src} alt={account?.get('acct')} />}
+    </div>
+  );
+};
+
+export default Avatar;

+ 1 - 0
app/javascript/mastodon/components/blurhash.jsx

@@ -44,6 +44,7 @@ function Blurhash({
       const ctx = canvas.getContext('2d');
       const imageData = new ImageData(pixels, width, height);
 
+      // @ts-expect-error
       ctx.putImageData(imageData, 0, 0);
     } catch (err) {
       console.error('Blurhash decoding failure', { err, hash });

+ 1 - 0
app/javascript/mastodon/components/common_counter.jsx

@@ -1,5 +1,6 @@
 // @ts-check
 import React from 'react';
+// @ts-expect-error
 import { FormattedMessage } from 'react-intl';
 
 /**

+ 10 - 2
app/javascript/mastodon/components/hashtag.jsx

@@ -1,11 +1,14 @@
 // @ts-check
 import React from 'react';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
+// @ts-expect-error
 import { FormattedMessage } from 'react-intl';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { Link } from 'react-router-dom';
+// @ts-expect-error
 import ShortNumber from 'mastodon/components/short_number';
+// @ts-expect-error
 import Skeleton from 'mastodon/components/skeleton';
 import classNames from 'classnames';
 
@@ -19,11 +22,11 @@ class SilentErrorBoundary extends React.Component {
     error: false,
   };
 
-  componentDidCatch () {
+  componentDidCatch() {
     this.setState({ error: true });
   }
 
-  render () {
+  render() {
     if (this.state.error) {
       return null;
     }
@@ -50,11 +53,13 @@ export const accountsCountRenderer = (displayNumber, pluralReady) => (
   />
 );
 
+// @ts-expect-error
 export const ImmutableHashtag = ({ hashtag }) => (
   <Hashtag
     name={hashtag.get('name')}
     to={`/tags/${hashtag.get('name')}`}
     people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+    // @ts-expect-error
     history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
   />
 );
@@ -63,6 +68,7 @@ ImmutableHashtag.propTypes = {
   hashtag: ImmutablePropTypes.map.isRequired,
 };
 
+// @ts-expect-error
 const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
   <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
@@ -86,7 +92,9 @@ const Hashtag = ({ name, to, people, uses, history, className, description, with
     {withGraph && (
       <div className='trends__item__sparkline'>
         <SilentErrorBoundary>
+          {/* @ts-expect-error */}
           <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
+            {/* @ts-expect-error */}
             <SparklinesCurve style={{ fill: 'none' }} />
           </Sparklines>
         </SilentErrorBoundary>

+ 1 - 1
app/javascript/mastodon/features/emoji/emoji_mart_data_light.js

@@ -9,7 +9,7 @@ const emojis = {};
 // decompress
 Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
   let [
-    filenameData, // eslint-disable-line no-unused-vars
+    filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
     searchData,
   ] = shortCodesToEmojiData[shortCode];
   let [

+ 3 - 3
app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js

@@ -4,9 +4,9 @@
 
 const [
   shortCodesToEmojiData,
-  skins, // eslint-disable-line no-unused-vars
-  categories, // eslint-disable-line no-unused-vars
-  short_names, // eslint-disable-line no-unused-vars
+  skins, // eslint-disable-line @typescript-eslint/no-unused-vars
+  categories, // eslint-disable-line @typescript-eslint/no-unused-vars
+  short_names, // eslint-disable-line @typescript-eslint/no-unused-vars
   emojisWithoutShortCodes,
 ] = require('./emoji_compressed');
 const { unicodeToFilename } = require('./unicode_to_filename');

+ 1 - 0
app/javascript/mastodon/initial_state.js

@@ -132,6 +132,7 @@ export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
 export const version = getMeta('version');
 export const languages = initialState?.languages;
+// @ts-expect-error
 export const statusPageUrl = getMeta('status_page_url');
 
 export default initialState;

+ 3 - 1
app/javascript/mastodon/is_mobile.js

@@ -1,6 +1,7 @@
 // @ts-check
 
 import { supportsPassiveEvents } from 'detect-passive-events';
+// @ts-expect-error
 import { forceSingleColumn } from 'mastodon/initial_state';
 
 const LAYOUT_BREAKPOINT = 630;
@@ -24,6 +25,7 @@ export const layoutFromWindow = () => {
   }
 };
 
+// @ts-expect-error
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
@@ -33,7 +35,7 @@ let userTouching = false;
 const touchListener = () => {
   userTouching = true;
 
-  window.removeEventListener('touchstart', touchListener, listenerOptions);
+  window.removeEventListener('touchstart', touchListener);
 };
 
 window.addEventListener('touchstart', touchListener, listenerOptions);

+ 22 - 12
app/javascript/mastodon/stream.js

@@ -59,6 +59,7 @@ const subscribe = ({ channelName, params, onConnect }) => {
   subscriptionCounters[key] = subscriptionCounters[key] || 0;
 
   if (subscriptionCounters[key] === 0) {
+    // @ts-expect-error
     sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params }));
   }
 
@@ -74,7 +75,9 @@ const unsubscribe = ({ channelName, params, onDisconnect }) => {
 
   subscriptionCounters[key] = subscriptionCounters[key] || 1;
 
+  // @ts-expect-error
   if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) {
+    // @ts-expect-error
     sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params }));
   }
 
@@ -83,11 +86,12 @@ const unsubscribe = ({ channelName, params, onDisconnect }) => {
 };
 
 const sharedCallbacks = {
-  connected () {
+  connected() {
     subscriptions.forEach(subscription => subscribe(subscription));
   },
 
-  received (data) {
+  // @ts-expect-error
+  received(data) {
     const { stream } = data;
 
     subscriptions.filter(({ channelName, params }) => {
@@ -111,11 +115,11 @@ const sharedCallbacks = {
     });
   },
 
-  disconnected () {
+  disconnected() {
     subscriptions.forEach(subscription => unsubscribe(subscription));
   },
 
-  reconnected () {
+  reconnected() {
   },
 };
 
@@ -138,6 +142,7 @@ const channelNameWithInlineParams = (channelName, params) => {
  * @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks
  * @return {function(): void}
  */
+// @ts-expect-error
 export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
   const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
   const accessToken = getState().getIn(['meta', 'access_token']);
@@ -147,19 +152,19 @@ export const connectStream = (channelName, params, callbacks) => (dispatch, getS
   // to using individual connections for each channel
   if (!streamingAPIBaseURL.startsWith('ws')) {
     const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), {
-      connected () {
+      connected() {
         onConnect();
       },
 
-      received (data) {
+      received(data) {
         onReceive(data);
       },
 
-      disconnected () {
+      disconnected() {
         onDisconnect();
       },
 
-      reconnected () {
+      reconnected() {
         onConnect();
       },
     });
@@ -227,14 +232,19 @@ const handleEventSourceMessage = (e, received) => {
 const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => {
   const params = channelName.split('&');
 
+  // @ts-expect-error
   channelName = params.shift();
 
   if (streamingAPIBaseURL.startsWith('ws')) {
+    // @ts-expect-error
     const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
 
-    ws.onopen      = connected;
-    ws.onmessage   = e => received(JSON.parse(e.data));
-    ws.onclose     = disconnected;
+    // @ts-expect-error
+    ws.onopen = connected;
+    ws.onmessage = e => received(JSON.parse(e.data));
+    // @ts-expect-error
+    ws.onclose = disconnected;
+    // @ts-expect-error
     ws.onreconnect = reconnected;
 
     return ws;
@@ -256,7 +266,7 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
   };
 
   KNOWN_EVENT_TYPES.forEach(type => {
-    es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received));
+    es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */(e), received));
   });
 
   es.onerror = /** @type {function(): void} */ (disconnected);

+ 1 - 1
app/javascript/mastodon/utils/notifications.js

@@ -3,7 +3,7 @@
 
 const checkNotificationPromise = () => {
   try {
-    // eslint-disable-next-line promise/catch-or-return, promise/valid-params
+    // eslint-disable-next-line promise/catch-or-return
     Notification.requestPermission().then();
   } catch(e) {
     return false;

+ 0 - 3
app/javascript/mastodon/uuid.js

@@ -1,3 +0,0 @@
-export default function uuid(a) {
-  return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
-}

+ 3 - 0
app/javascript/mastodon/uuid.ts

@@ -0,0 +1,3 @@
+export default function uuid(a?: string): string {
+  return a ? ((a as any as number) ^ Math.random() * 16 >> (a as any as number) / 4).toString(16) : ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
+}

+ 0 - 1
app/javascript/packs/public-path.js

@@ -17,5 +17,4 @@ function formatPublicPath(host = '', path = '') {
 
 const cdnHost = document.querySelector('meta[name=cdn-host]');
 
-// eslint-disable-next-line no-undef
 __webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH);

+ 13 - 0
app/javascript/types/resources.ts

@@ -0,0 +1,13 @@
+interface MastodonMap<T> {
+  get<K extends keyof T>(key: K): T[K];
+  has<K extends keyof T>(key: K): boolean;
+  set<K extends keyof T>(key: K, value: T[K]): this;
+}
+
+type AccountValues = {
+  id: number;
+  avatar: string;
+  avatar_static: string;
+  [key: string]: any;
+}
+export type Account = MastodonMap<AccountValues>

+ 1 - 0
babel.config.js

@@ -13,6 +13,7 @@ module.exports = (api) => {
 
   const config = {
     presets: [
+      '@babel/preset-typescript',
       ['@babel/react', reactOptions],
       ['@babel/env', envOptions],
     ],

+ 1 - 1
config/webpack/rules/babel.js

@@ -2,7 +2,7 @@ const { join, resolve } = require('path');
 const { env, settings } = require('../configuration');
 
 module.exports = {
-  test: /\.(js|jsx|mjs)$/,
+  test: /\.(js|jsx|mjs|ts|tsx)$/,
   include: [
     settings.source_path,
     ...settings.resolved_paths,

+ 2 - 0
config/webpacker.yml

@@ -36,6 +36,8 @@ default: &default
     - .mjs
     - .js
     - .jsx
+    - .ts
+    - .tsx
     - .sass
     - .scss
     - .css

+ 41 - 3
package.json

@@ -10,10 +10,11 @@
     "build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack",
     "manage:translations": "node ./config/webpack/translationRunner.js",
     "start": "node ./streaming/index.js",
-    "test": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:jest",
+    "test": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:typecheck && ${npm_execpath} run test:jest",
     "test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass",
-    "test:lint:js": "eslint --ext=.js,.jsx . --cache --report-unused-disable-directives",
+    "test:lint:js": "eslint --ext=.js,.jsx,.ts,.tsx . --cache --report-unused-disable-directives",
     "test:lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
+    "test:typecheck": "tsc --noEmit",
     "test:jest": "cross-env NODE_ENV=test jest",
     "format": "prettier --write .",
     "format-check": "prettier --check .",
@@ -139,9 +140,45 @@
     "ws": "^8.12.1"
   },
   "devDependencies": {
-    "@babel/eslint-parser": "^7.21.3",
+    "@babel/preset-typescript": "^7.21.0",
     "@testing-library/jest-dom": "^5.16.5",
     "@testing-library/react": "^12.1.5",
+    "@types/babel__core": "^7.20.0",
+    "@types/emoji-mart": "^3.0.9",
+    "@types/escape-html": "^1.0.2",
+    "@types/eslint": "^8.21.2",
+    "@types/express": "^4.17.17",
+    "@types/glob": "^8.1.0",
+    "@types/http-link-header": "^1.0.3",
+    "@types/intl": "^1.2.0",
+    "@types/jest": "^29.4.2",
+    "@types/js-yaml": "^4.0.5",
+    "@types/lodash": "^4.14.191",
+    "@types/npmlog": "^4.1.4",
+    "@types/object-assign": "^4.0.30",
+    "@types/pg": "^8.6.6",
+    "@types/prop-types": "^15.7.5",
+    "@types/punycode": "^2.1.0",
+    "@types/raf": "^3.4.0",
+    "@types/react": "^18.0.28",
+    "@types/react-dom": "^18.0.11",
+    "@types/react-intl": "2.3.18",
+    "@types/react-motion": "^0.0.33",
+    "@types/react-redux": "^7.1.25",
+    "@types/react-router-dom": "^5.3.3",
+    "@types/react-sparklines": "^1.7.2",
+    "@types/react-swipeable-views": "^0.13.1",
+    "@types/react-test-renderer": "^18.0.0",
+    "@types/react-toggle": "^4.0.3",
+    "@types/redux-immutable": "^4.0.3",
+    "@types/requestidlecallback": "^0.3.5",
+    "@types/throng": "^5.0.4",
+    "@types/uuid": "^9.0.1",
+    "@types/webpack": "^5.28.0",
+    "@types/webpack-bundle-analyzer": "^4.6.0",
+    "@types/yargs": "^17.0.22",
+    "@typescript-eslint/eslint-plugin": "^5.55.0",
+    "@typescript-eslint/parser": "^5.55.0",
     "babel-jest": "^29.5.0",
     "eslint": "^8.36.0",
     "eslint-plugin-import": "~2.27.5",
@@ -160,6 +197,7 @@
     "react-test-renderer": "^16.14.0",
     "stylelint": "^15.3.0",
     "stylelint-config-standard-scss": "^7.0.1",
+    "typescript": "^4.9.5",
     "webpack-dev-server": "^3.11.3",
     "yargs": "^17.7.1"
   },

+ 13 - 0
tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "jsx": "react",
+    "target": "esnext",
+    "moduleResolution": "node",
+    "allowJs": true,
+    "noEmit": true,
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true
+  },
+  "include": ["app/javascript/mastodon", "app/javascript/packs"]
+}

File diff suppressed because it is too large
+ 599 - 90
yarn.lock


Some files were not shown because too many files changed in this diff