From 4520e6473afe901005e7ac532317f4b4f1af9ead Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki <24884114+takayamaki@users.noreply.github.com> Date: Mon, 3 Apr 2023 10:31:39 +0900 Subject: [Proposal] Make able to write React in Typescript (#16210) Co-authored-by: berlysia Co-authored-by: fusagiko / takayamaki --- app/javascript/hooks/useHovering.ts | 17 ++++++ .../mastodon/actions/picture_in_picture.js | 1 + app/javascript/mastodon/actions/streaming.js | 20 +++++-- app/javascript/mastodon/api.js | 4 +- app/javascript/mastodon/components/avatar.jsx | 62 ---------------------- app/javascript/mastodon/components/avatar.tsx | 40 ++++++++++++++ app/javascript/mastodon/components/blurhash.jsx | 1 + .../mastodon/components/common_counter.jsx | 1 + app/javascript/mastodon/components/hashtag.jsx | 12 ++++- .../features/emoji/emoji_mart_data_light.js | 2 +- .../features/emoji/emoji_unicode_mapping_light.js | 6 +-- app/javascript/mastodon/initial_state.js | 1 + app/javascript/mastodon/is_mobile.js | 4 +- app/javascript/mastodon/stream.js | 34 +++++++----- app/javascript/mastodon/utils/notifications.js | 2 +- app/javascript/mastodon/uuid.js | 3 -- app/javascript/mastodon/uuid.ts | 3 ++ app/javascript/packs/public-path.js | 1 - app/javascript/types/resources.ts | 13 +++++ 19 files changed, 135 insertions(+), 92 deletions(-) create mode 100644 app/javascript/hooks/useHovering.ts delete mode 100644 app/javascript/mastodon/components/avatar.jsx create mode 100644 app/javascript/mastodon/components/avatar.tsx delete mode 100644 app/javascript/mastodon/uuid.js create mode 100644 app/javascript/mastodon/uuid.ts create mode 100644 app/javascript/types/resources.ts (limited to 'app') diff --git a/app/javascript/hooks/useHovering.ts b/app/javascript/hooks/useHovering.ts new file mode 100644 index 000000000..2062e70d2 --- /dev/null +++ b/app/javascript/hooks/useHovering.ts @@ -0,0 +1,17 @@ +import { useCallback, useState } from 'react'; + +export const useHovering = (animate?: boolean) => { + const [hovering, setHovering] = useState(animate ?? false); + + const handleMouseEnter = useCallback(() => { + if (animate) return; + setHovering(true); + }, [animate]); + + const handleMouseLeave = useCallback(() => { + if (animate) return; + setHovering(false); + }, [animate]); + + return { hovering, handleMouseEnter, handleMouseLeave }; +}; diff --git a/app/javascript/mastodon/actions/picture_in_picture.js b/app/javascript/mastodon/actions/picture_in_picture.js index 33d8d57d4..6b9ff7709 100644 --- a/app/javascript/mastodon/actions/picture_in_picture.js +++ b/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])) { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 84709083f..4d4ea83e4 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/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 }); /** diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js index 6bbddbef6..42b64d6cc 100644 --- a/app/javascript/mastodon/api.js +++ b/app/javascript/mastodon/api.js @@ -36,7 +36,7 @@ const setCSRFHeader = () => { ready(setCSRFHeader); /** - * @param {() => import('immutable').Map} getState + * @param {() => import('immutable').Map} getState * @returns {import('axios').RawAxiosRequestHeaders} */ const authorizationHeaderFromState = getState => { @@ -52,7 +52,7 @@ const authorizationHeaderFromState = getState => { }; /** - * @param {() => import('immutable').Map} getState + * @param {() => import('immutable').Map} getState * @returns {import('axios').AxiosInstance} */ export default function api(getState) { diff --git a/app/javascript/mastodon/components/avatar.jsx b/app/javascript/mastodon/components/avatar.jsx deleted file mode 100644 index 013454ccf..000000000 --- a/app/javascript/mastodon/components/avatar.jsx +++ /dev/null @@ -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 ( -
- {src && {account?.get('acct')}} -
- ); - } - -} diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx new file mode 100644 index 000000000..54ac611e6 --- /dev/null +++ b/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 = ({ + 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 ( +
+ {src && {account?.get('acct')}} +
+ ); +}; + +export default Avatar; diff --git a/app/javascript/mastodon/components/blurhash.jsx b/app/javascript/mastodon/components/blurhash.jsx index 2af5cfc56..07cd31b6c 100644 --- a/app/javascript/mastodon/components/blurhash.jsx +++ b/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 }); diff --git a/app/javascript/mastodon/components/common_counter.jsx b/app/javascript/mastodon/components/common_counter.jsx index dd9b62de9..d97e7e7d6 100644 --- a/app/javascript/mastodon/components/common_counter.jsx +++ b/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'; /** diff --git a/app/javascript/mastodon/components/hashtag.jsx b/app/javascript/mastodon/components/hashtag.jsx index e516fc086..19d87508b 100644 --- a/app/javascript/mastodon/components/hashtag.jsx +++ b/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 }) => ( 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 }) => (
@@ -86,7 +92,9 @@ const Hashtag = ({ name, to, people, uses, history, className, description, with {withGraph && (
+ {/* @ts-expect-error */} 0)}> + {/* @ts-expect-error */} diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js index 45086fc4c..49813537d 100644 --- a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js +++ b/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 [ diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js index 918684c31..1a38fde23 100644 --- a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js +++ b/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'); diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 919e0fc28..150776f84 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/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; diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 3c8ec1545..d0669b74b 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/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); diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index c6d12cd6f..95e0359d0 100644 --- a/app/javascript/mastodon/stream.js +++ b/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); diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js index 3cdf7caea..7634cac21 100644 --- a/app/javascript/mastodon/utils/notifications.js +++ b/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; diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js deleted file mode 100644 index 0d2cfaa77..000000000 --- a/app/javascript/mastodon/uuid.js +++ /dev/null @@ -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); -} diff --git a/app/javascript/mastodon/uuid.ts b/app/javascript/mastodon/uuid.ts new file mode 100644 index 000000000..655bcf78c --- /dev/null +++ b/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); +} diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js index f4d166a77..539e3b8c4 100644 --- a/app/javascript/packs/public-path.js +++ b/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); diff --git a/app/javascript/types/resources.ts b/app/javascript/types/resources.ts new file mode 100644 index 000000000..efbb1faa7 --- /dev/null +++ b/app/javascript/types/resources.ts @@ -0,0 +1,13 @@ +interface MastodonMap { + get(key: K): T[K]; + has(key: K): boolean; + set(key: K, value: T[K]): this; +} + +type AccountValues = { + id: number; + avatar: string; + avatar_static: string; + [key: string]: any; +} +export type Account = MastodonMap -- cgit