diff --git a/.nvmrc b/.nvmrc index a3597ecbd1..7795cadb57 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.11 +20.12 diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js deleted file mode 100644 index cfc329a8b7..0000000000 --- a/app/javascript/mastodon/actions/markers.js +++ /dev/null @@ -1,152 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { debounce } from 'lodash'; - -import api from '../api'; -import { compareId } from '../compare_id'; - -export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; -export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; -export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; -export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; - -export const synchronouslySubmitMarkers = () => (dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - // The Fetch API allows us to perform requests that will be carried out - // after the page closes. But that only works if the `keepalive` attribute - // is supported. - if (window.fetch && 'keepalive' in new Request('')) { - fetch('/api/v1/markers', { - keepalive: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify(params), - }); - - return; - } else if (navigator && navigator.sendBeacon) { - // Failing that, we can use sendBeacon, but we have to encode the data as - // FormData for DoorKeeper to recognize the token. - const formData = new FormData(); - - formData.append('bearer_token', accessToken); - - for (const [id, value] of Object.entries(params)) { - formData.append(`${id}[last_read_id]`, value.last_read_id); - } - - if (navigator.sendBeacon('/api/v1/markers', formData)) { - return; - } - } - - // If neither Fetch nor sendBeacon worked, try to perform a synchronous - // request. - try { - const client = new XMLHttpRequest(); - - client.open('POST', '/api/v1/markers', false); - client.setRequestHeader('Content-Type', 'application/json'); - client.setRequestHeader('Authorization', `Bearer ${accessToken}`); - client.send(JSON.stringify(params)); - } catch (e) { - // Do not make the BeforeUnload handler error out - } -}; - -const _buildParams = (state) => { - const params = {}; - - const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); - const lastNotificationId = state.getIn(['notifications', 'lastReadId']); - - if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { - params.home = { - last_read_id: lastHomeId, - }; - } - - if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { - params.notifications = { - last_read_id: lastNotificationId, - }; - } - - return params; -}; - -const debouncedSubmitMarkers = debounce((dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - api(getState).post('/api/v1/markers', params).then(() => { - dispatch(submitMarkersSuccess(params)); - }).catch(() => {}); -}, 300000, { leading: true, trailing: true }); - -export function submitMarkersSuccess({ home, notifications }) { - return { - type: MARKERS_SUBMIT_SUCCESS, - home: (home || {}).last_read_id, - notifications: (notifications || {}).last_read_id, - }; -} - -export function submitMarkers(params = {}) { - const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); - - if (params.immediate === true) { - debouncedSubmitMarkers.flush(); - } - - return result; -} - -export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; - - dispatch(fetchMarkersRequest()); - - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); -}; - -export function fetchMarkersRequest() { - return { - type: MARKERS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchMarkersSuccess(markers) { - return { - type: MARKERS_FETCH_SUCCESS, - markers, - skipLoading: true, - }; -} - -export function fetchMarkersFail(error) { - return { - type: MARKERS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts new file mode 100644 index 0000000000..84e5b33bcc --- /dev/null +++ b/app/javascript/mastodon/actions/markers.ts @@ -0,0 +1,165 @@ +import { List as ImmutableList } from 'immutable'; + +import { debounce } from 'lodash'; + +import type { MarkerJSON } from 'mastodon/api_types/markers'; +import type { RootState } from 'mastodon/store'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api, { authorizationTokenFromState } from '../api'; +import { compareId } from '../compare_id'; + +export const synchronouslySubmitMarkers = createAppAsyncThunk( + 'markers/submit', + async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || !accessToken) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if ('fetch' in window && 'keepalive' in new Request('')) { + await fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if ('navigator' && 'sendBeacon' in navigator) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + if (value.last_read_id) + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } + }, +); + +interface MarkerParam { + last_read_id?: string; +} + +function getLastHomeId(state: RootState): string | undefined { + /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + return ( + state + // @ts-expect-error state.timelines is not yet typed + .getIn(['timelines', 'home', 'items'], ImmutableList()) + // @ts-expect-error state.timelines is not yet typed + .find((item) => item !== null) + ); +} + +function getLastNotificationId(state: RootState): string | undefined { + // @ts-expect-error state.notifications is not yet typed + return state.getIn(['notifications', 'lastReadId']); +} + +const buildPostMarkersParams = (state: RootState) => { + const params = {} as { home?: MarkerParam; notifications?: MarkerParam }; + + const lastHomeId = getLastHomeId(state); + const lastNotificationId = getLastNotificationId(state); + + if (lastHomeId && compareId(lastHomeId, state.markers.home) > 0) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if ( + lastNotificationId && + compareId(lastNotificationId, state.markers.notifications) > 0 + ) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +export const submitMarkersAction = createAppAsyncThunk<{ + home: string | undefined; + notifications: string | undefined; +}>('markers/submitAction', async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return { home: undefined, notifications: undefined }; + } + + await api(getState).post('/api/v1/markers', params); + + return { + home: params.home?.last_read_id, + notifications: params.notifications?.last_read_id, + }; +}); + +const debouncedSubmitMarkers = debounce( + (dispatch) => { + dispatch(submitMarkersAction()); + }, + 300000, + { + leading: true, + trailing: true, + }, +); + +export const submitMarkers = createAppAsyncThunk( + 'markers/submit', + (params: { immediate?: boolean }, { dispatch }) => { + debouncedSubmitMarkers(dispatch); + + if (params.immediate) { + debouncedSubmitMarkers.flush(); + } + }, +); + +export const fetchMarkers = createAppAsyncThunk( + 'markers/fetch', + async (_args, { getState }) => { + const response = await api(getState).get>( + `/api/v1/markers`, + { params: { timeline: ['notifications'] } }, + ); + + return { markers: response.data }; + }, +); diff --git a/app/javascript/mastodon/actions/picture_in_picture.js b/app/javascript/mastodon/actions/picture_in_picture.js deleted file mode 100644 index 898375abeb..0000000000 --- a/app/javascript/mastodon/actions/picture_in_picture.js +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-check - -export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; -export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; - -/** - * @typedef MediaProps - * @property {string} src - * @property {boolean} muted - * @property {number} volume - * @property {number} currentTime - * @property {string} poster - * @property {string} backgroundColor - * @property {string} foregroundColor - * @property {string} accentColor - */ - -/** - * @param {string} statusId - * @param {string} accountId - * @param {string} playerType - * @param {MediaProps} props - * @returns {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])) { - dispatch({ - type: PICTURE_IN_PICTURE_DEPLOY, - statusId, - accountId, - playerType, - props, - }); - } - }; -}; - -/* - * @return {object} - */ -export const removePictureInPicture = () => ({ - type: PICTURE_IN_PICTURE_REMOVE, -}); diff --git a/app/javascript/mastodon/actions/picture_in_picture.ts b/app/javascript/mastodon/actions/picture_in_picture.ts new file mode 100644 index 0000000000..d34b508a33 --- /dev/null +++ b/app/javascript/mastodon/actions/picture_in_picture.ts @@ -0,0 +1,31 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { PIPMediaProps } from 'mastodon/reducers/picture_in_picture'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +interface DeployParams { + statusId: string; + accountId: string; + playerType: 'audio' | 'video'; + props: PIPMediaProps; +} + +export const removePictureInPicture = createAction('pip/remove'); + +export const deployPictureInPictureAction = + createAction('pip/deploy'); + +export const deployPictureInPicture = createAppAsyncThunk( + 'pip/deploy', + (args: DeployParams, { dispatch, getState }) => { + const { statusId } = args; + + // Do not open a player for a toot that does not exist + + // @ts-expect-error state.statuses is not yet typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (getState().hasIn(['statuses', statusId])) { + dispatch(deployPictureInPictureAction(args)); + } + }, +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index f262fd8570..de597a3e3b 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -29,9 +29,14 @@ const setCSRFHeader = () => { void ready(setCSRFHeader); +export const authorizationTokenFromState = (getState?: GetState) => { + return ( + getState && (getState().meta.get('access_token', '') as string | false) + ); +}; + const authorizationHeaderFromState = (getState?: GetState) => { - const accessToken = - getState && (getState().meta.get('access_token', '') as string); + const accessToken = authorizationTokenFromState(getState); if (!accessToken) { return {}; diff --git a/app/javascript/mastodon/api_types/markers.ts b/app/javascript/mastodon/api_types/markers.ts new file mode 100644 index 0000000000..f7664fd7c1 --- /dev/null +++ b/app/javascript/mastodon/api_types/markers.ts @@ -0,0 +1,7 @@ +// See app/serializers/rest/account_serializer.rb + +export interface MarkerJSON { + last_read_id: string; + version: string; + updated_at: string; +} diff --git a/app/javascript/mastodon/components/button.tsx b/app/javascript/mastodon/components/button.tsx index 0b6a0f267e..c76aaea42f 100644 --- a/app/javascript/mastodon/components/button.tsx +++ b/app/javascript/mastodon/components/button.tsx @@ -1,26 +1,26 @@ +import type { PropsWithChildren } from 'react'; import { useCallback } from 'react'; import classNames from 'classnames'; -interface BaseProps extends React.ButtonHTMLAttributes { +interface BaseProps + extends Omit, 'children'> { block?: boolean; secondary?: boolean; - text?: JSX.Element; } -interface PropsWithChildren extends BaseProps { - text?: never; +interface PropsChildren extends PropsWithChildren { + text?: undefined; } interface PropsWithText extends BaseProps { - text: JSX.Element; - children: never; + text: JSX.Element | string; + children?: undefined; } -type Props = PropsWithText | PropsWithChildren; +type Props = PropsWithText | PropsChildren; export const Button: React.FC = ({ - text, type = 'button', onClick, disabled, @@ -28,6 +28,7 @@ export const Button: React.FC = ({ secondary, className, title, + text, children, ...props }) => { diff --git a/app/javascript/mastodon/components/relative_timestamp.tsx b/app/javascript/mastodon/components/relative_timestamp.tsx index b9e1e4f8fd..ac385e88c6 100644 --- a/app/javascript/mastodon/components/relative_timestamp.tsx +++ b/app/javascript/mastodon/components/relative_timestamp.tsx @@ -191,7 +191,7 @@ const timeRemainingString = ( interface Props { intl: IntlShape; timestamp: string; - year: number; + year?: number; futureDate?: boolean; short?: boolean; } @@ -203,11 +203,6 @@ class RelativeTimestamp extends Component { now: Date.now(), }; - static defaultProps = { - year: new Date().getFullYear(), - short: true, - }; - _timer: number | undefined; shouldComponentUpdate(nextProps: Props, nextState: States) { @@ -257,7 +252,13 @@ class RelativeTimestamp extends Component { } render() { - const { timestamp, intl, year, futureDate, short } = this.props; + const { + timestamp, + intl, + futureDate, + year = new Date().getFullYear(), + short = true, + } = this.props; const timeGiven = timestamp.includes('T'); const date = new Date(timestamp); diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 3c6d26e349..6def49fdbb 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -81,7 +81,7 @@ class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, - relationship: ImmutablePropTypes.map, + relationship: ImmutablePropTypes.record, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 7bd91c8c9d..da93a16b08 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -262,7 +262,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ }, deployPictureInPicture (status, type, mediaProps) { - dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); + dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps})); }, onInteractionModal (type, status) { diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index a686a0a849..db6e0f6ecd 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -82,7 +82,7 @@ class GettingStarted extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, - myAccount: ImmutablePropTypes.map, + myAccount: ImmutablePropTypes.record, multiColumn: PropTypes.bool, fetchFollowRequests: PropTypes.func.isRequired, unreadFollowRequests: PropTypes.number, diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx index 8dfbf54cb6..a7d8356be7 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx @@ -210,4 +210,4 @@ class Footer extends ImmutablePureComponent { } -export default withRouter(connect(makeMapStateToProps)(injectIntl(Footer))); +export default connect(makeMapStateToProps)(withRouter(injectIntl(Footer))); diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.jsx b/app/javascript/mastodon/features/picture_in_picture/components/header.jsx deleted file mode 100644 index 31073d7387..0000000000 --- a/app/javascript/mastodon/features/picture_in_picture/components/header.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Avatar } from 'mastodon/components/avatar'; -import { DisplayName } from 'mastodon/components/display_name'; -import { IconButton } from 'mastodon/components/icon_button'; - -const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, -}); - -const mapStateToProps = (state, { accountId }) => ({ - account: state.getIn(['accounts', accountId]), -}); - -class Header extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - statusId: PropTypes.string.isRequired, - account: ImmutablePropTypes.record.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - render () { - const { account, statusId, onClose, intl } = this.props; - - return ( -
- - - - - - -
- ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.tsx b/app/javascript/mastodon/features/picture_in_picture/components/header.tsx new file mode 100644 index 0000000000..0f897dc441 --- /dev/null +++ b/app/javascript/mastodon/features/picture_in_picture/components/header.tsx @@ -0,0 +1,46 @@ +import { defineMessages, useIntl } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { Avatar } from 'mastodon/components/avatar'; +import { DisplayName } from 'mastodon/components/display_name'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +interface Props { + accountId: string; + statusId: string; + onClose: () => void; +} + +export const Header: React.FC = ({ accountId, statusId, onClose }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + + const intl = useIntl(); + + if (!account) return null; + + return ( +
+ + + + + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/picture_in_picture/index.jsx b/app/javascript/mastodon/features/picture_in_picture/index.jsx deleted file mode 100644 index f087cd1b1d..0000000000 --- a/app/javascript/mastodon/features/picture_in_picture/index.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import { Component } from 'react'; - -import { connect } from 'react-redux'; - -import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; -import Audio from 'mastodon/features/audio'; -import Video from 'mastodon/features/video'; - -import Footer from './components/footer'; -import Header from './components/header'; - -const mapStateToProps = state => ({ - ...state.get('picture_in_picture'), -}); - -class PictureInPicture extends Component { - - static propTypes = { - statusId: PropTypes.string, - accountId: PropTypes.string, - type: PropTypes.string, - src: PropTypes.string, - muted: PropTypes.bool, - volume: PropTypes.number, - currentTime: PropTypes.number, - poster: PropTypes.string, - backgroundColor: PropTypes.string, - foregroundColor: PropTypes.string, - accentColor: PropTypes.string, - dispatch: PropTypes.func.isRequired, - }; - - handleClose = () => { - const { dispatch } = this.props; - dispatch(removePictureInPicture()); - }; - - render () { - const { type, src, currentTime, accountId, statusId } = this.props; - - if (!currentTime) { - return null; - } - - let player; - - if (type === 'video') { - player = ( -