diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 310ac5f84dc..2cc4526b828 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -3,24 +3,32 @@ import { defineMessages } from 'react-intl'; import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; -import { apiUpdateMedia } from 'mastodon/api/compose'; -import { apiGetSearch } from 'mastodon/api/search'; -import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; -import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { apiUpdateMedia } from '@/mastodon/api/compose'; +import { apiGetSearch } from '@/mastodon/api/search'; +import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments'; +import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; +import type { MediaAttachment } from '@/mastodon/models/media_attachment'; +import type { Status, StatusVisibility } from '@/mastodon/models/status'; +import type { RootState } from '@/mastodon/store'; import { createDataLoadingThunk, createAppThunk, -} from 'mastodon/store/typed_functions'; +} from '@/mastodon/store/typed_functions'; -import type { ApiQuotePolicy } from '../api_types/quotes'; -import type { Status, StatusVisibility } from '../models/status'; -import type { RootState } from '../store'; +import type { ApiStatusJSON } from '../api_types/statuses'; import { showAlert } from './alerts'; -import { changeCompose, focusCompose } from './compose'; +import { + changeCompose, + focusCompose, + submitCompose as submitComposeApi, + uploadCompose, +} from './compose'; import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; +export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify'; + const messages = defineMessages({ quoteErrorEdit: { id: 'quote_error.edit', @@ -159,13 +167,11 @@ export const quoteComposeByStatus = createAppThunk( const state = getState(); const composeState = state.compose; const mediaAttachments = composeState.get('media_attachments'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const wasQuietPostHintModalDismissed: boolean = - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - state.settings.getIn( - ['dismissed_banners', 'quote/quiet_post_hint'], - false, - ); + + const wasQuietPostHintModalDismissed = !!state.settings.getIn( + ['dismissed_banners', 'quote/quiet_post_hint'], + false, + ); if (composeState.get('id')) { dispatch(showAlert({ message: messages.quoteErrorEdit })); @@ -278,3 +284,85 @@ export const setComposeQuotePolicy = createAction( export const setDragUploadEnabled = createAction( 'compose/setDragUploadEnabled', ); + +export const submitCompose = createAppThunk( + ( + { + textareaValue = '', + redirectOnSuccess, + }: { textareaValue?: string; redirectOnSuccess?: boolean }, + { getState, dispatch }, + ) => { + if ( + textareaValue && + (getState().compose.get('text') as string) !== textareaValue + ) { + dispatch(changeCompose(textareaValue)); + } + + const { compose, meta, statuses, settings } = getState(); + const privacy = compose.get('privacy') as StatusVisibility; + const missingAltText = ( + compose.get('media_attachments') as unknown as Immutable.List< + Immutable.Map + > + ).some( + (media) => + ['image', 'gifv'].includes(media.get('type') ?? '') && + (media.get('description') ?? '').length === 0, + ); + const me = meta.get('me') as string | null; + const quotedStatusId = compose.get('quoted_status_id') as string | null; + const quoteToPrivate = + !!quotedStatusId && + privacy === 'private' && + statuses.getIn([quotedStatusId, 'account']) !== me && + !settings.getIn(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID]); + + if ( + !!meta.get('missing_alt_text_modal') && + missingAltText && + privacy !== 'direct' + ) { + dispatch( + openModal({ + modalType: 'CONFIRM_MISSING_ALT_TEXT', + modalProps: {}, + }), + ); + } else if (quoteToPrivate) { + dispatch( + openModal({ + modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY', + modalProps: {}, + }), + ); + } else { + dispatch( + submitComposeApi((status: ApiStatusJSON) => { + if (redirectOnSuccess) { + window.location.assign(status.url); + } + }), + ); + } + }, +); + +const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i; + +export const processPasteOrDrop = createAppThunk( + (transfer: DataTransfer, { dispatch }) => { + if (transfer.files.length === 1) { + dispatch(uploadCompose(transfer.files)); + } else if (transfer.files.length === 0) { + const data = transfer.getData('text/plain'); + if (!urlLikeRegex.exec(data)) return; + + try { + const url = new URL(data).toString(); + void dispatch(pasteLinkCompose({ url })); + } catch {} + } + }, +); diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx index c8506905312..ebf577c0587 100644 --- a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx +++ b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx @@ -38,7 +38,7 @@ type Language = [string, string, string]; const getFrequentlyUsedLanguages = createSelector( [ (state: RootState) => - (state.settings as ImmutableMap).get( + state.settings.get( 'frequentlyUsedLanguages', ImmutableMap(), ) as ImmutableMap, diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index c5cffff10d5..f2b1fee849d 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -10,9 +10,8 @@ import { insertEmojiCompose, uploadCompose, } from 'mastodon/actions/compose'; -import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; +import { pasteLinkCompose, PRIVATE_QUOTE_MODAL_ID } from 'mastodon/actions/compose_typed'; import { openModal } from 'mastodon/actions/modal'; -import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify'; import { me } from 'mastodon/initial_state'; import ComposeForm from '../components/compose_form'; diff --git a/app/javascript/mastodon/features/compose/index.tsx b/app/javascript/mastodon/features/compose/index.tsx index 8d9db224701..8d9dc68cbee 100644 --- a/app/javascript/mastodon/features/compose/index.tsx +++ b/app/javascript/mastodon/features/compose/index.tsx @@ -52,10 +52,7 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const columns = useAppSelector( - (state) => - (state.settings as ImmutableMap).get( - 'columns', - ) as ImmutableList, + (state) => state.settings.get('columns') as ImmutableList, ); useEffect(() => { diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.tsx b/app/javascript/mastodon/features/home_timeline/components/column_settings.tsx index 7de82215e4f..6257eae34ff 100644 --- a/app/javascript/mastodon/features/home_timeline/components/column_settings.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.tsx @@ -1,8 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call, - @typescript-eslint/no-unsafe-return, - @typescript-eslint/no-unsafe-assignment, - @typescript-eslint/no-unsafe-member-access - -- the settings store is not yet typed */ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx index e48fd19c3bd..2d849b46ffb 100644 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -174,9 +174,7 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({ const suggestions = useAppSelector((state) => state.suggestions.items); const isLoading = useAppSelector((state) => state.suggestions.isLoading); const dismissed = useAppSelector( - (state) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, + (state) => !!state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]), ); useEffect(() => { diff --git a/app/javascript/mastodon/features/notifications/components/column_settings_group.tsx b/app/javascript/mastodon/features/notifications/components/column_settings_group.tsx index f888488be32..57c8d0ed7eb 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings_group.tsx +++ b/app/javascript/mastodon/features/notifications/components/column_settings_group.tsx @@ -18,9 +18,10 @@ import SettingToggle from './setting_toggle'; const selectNotificationSettings = createAppSelector( [ (state) => - (state.settings as Immutable.Map).get( - 'notifications', - ) as Immutable.Map | boolean>, + state.settings.get('notifications') as Immutable.Map< + string, + Immutable.Map | boolean + >, (state) => state.notifications.get('browserPermission') as string, (state) => state.push_notifications, ], diff --git a/app/javascript/mastodon/features/ui/components/columns_area.tsx b/app/javascript/mastodon/features/ui/components/columns_area.tsx index be91d5dccce..0e0d0884bef 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.tsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.tsx @@ -93,10 +93,8 @@ export const ColumnsArea = forwardRef< } >(({ children, minimalShell, singleColumn }, ref) => { const renderComposePanel = !useBreakpoint('full'); - const columns = useAppSelector((state) => - (state.settings as Record<{ columns: List> }>).get( - 'columns', - ), + const columns = useAppSelector( + (state) => state.settings.get('columns') as List>, ); const isModalOpen = useAppSelector( (state) => !state.modal.get('stack').isEmpty(), diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx index ef917a10270..42fc15b266f 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/private_quote_notify.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { submitCompose } from '@/mastodon/actions/compose'; +import { PRIVATE_QUOTE_MODAL_ID } from '@/mastodon/actions/compose_typed'; import { changeSetting } from '@/mastodon/actions/settings'; import { CheckBox } from '@/mastodon/components/check_box'; import { useAppDispatch } from '@/mastodon/store'; @@ -11,8 +12,6 @@ import { ConfirmationModal } from './confirmation_modal'; import type { BaseConfirmationModalProps } from './confirmation_modal'; import classes from './styles.module.css'; -export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify'; - const messages = defineMessages({ title: { id: 'confirmations.private_quote_notify.title', diff --git a/app/javascript/mastodon/features/ui/components/skip_links/index.tsx b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx index 7ecff95287c..e5a74ba992b 100644 --- a/app/javascript/mastodon/features/ui/components/skip_links/index.tsx +++ b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx @@ -16,7 +16,7 @@ export const SkipLinks: React.FC<{ }> = ({ multiColumn, onFocusGettingStartedColumn }) => { const intl = useIntl(); const columnCount = useAppSelector((state) => { - const settings = state.settings as Immutable.Collection; + const settings = state.settings; return (settings.get('columns') as Immutable.Map).size; }); diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index c10d3506002..136d57f4a50 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -165,6 +165,7 @@ const updateFrequentLanguages = (state, language) => state.update('frequentlyUse const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); +/** @type {import('@reduxjs/toolkit').Reducer>} */ export default function settings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: diff --git a/app/javascript/mastodon/selectors/settings.ts b/app/javascript/mastodon/selectors/settings.ts index 49e0df0c404..7958c0dd483 100644 --- a/app/javascript/mastodon/selectors/settings.ts +++ b/app/javascript/mastodon/selectors/settings.ts @@ -48,7 +48,12 @@ export const selectSettingsNotificationsShowUnread = (state: RootState) => state.settings.getIn(['notifications', 'showUnread']) as boolean; export const selectNeedsNotificationPermission = (state: RootState) => - (state.settings.getIn(['notifications', 'alerts']).includes(true) && + (( + state.settings.getIn(['notifications', 'alerts']) as Immutable.Map< + string, + boolean + > + ).includes(true) && state.notifications.get('browserSupport') && state.notifications.get('browserPermission') === 'default' && !state.settings.getIn([