diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index 8e5e281bd..11de952f2 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -68,9 +68,9 @@ export const ProfileHoverCard: React.FC = ({ visible = true } const [popperElement, setPopperElement] = useState(null); const me = useAppSelector(state => state.me); - const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.get('accountId', undefined)); + const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.accountId || undefined); const account = useAppSelector(state => accountId && getAccount(state, accountId)); - const targetRef = useAppSelector(state => state.profile_hover_card.getIn(['ref', 'current']) as Element | null); + const targetRef = useAppSelector(state => state.profile_hover_card.ref?.current); const badges = account ? getBadges(account) : []; useEffect(() => { diff --git a/app/soapbox/features/emoji/emoji_mart_data_light.js b/app/soapbox/features/emoji/emoji_mart_data_light.ts similarity index 67% rename from app/soapbox/features/emoji/emoji_mart_data_light.js rename to app/soapbox/features/emoji/emoji_mart_data_light.ts index 4756c1a5d..03bdbf765 100644 --- a/app/soapbox/features/emoji/emoji_mart_data_light.js +++ b/app/soapbox/features/emoji/emoji_mart_data_light.ts @@ -1,15 +1,17 @@ // The output of this module is designed to mimic emoji-mart's // "data" object, such that we can use it for a light version of emoji-mart's // emojiIndex.search functionality. -const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed'); -const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); +import emojiCompressed from './emoji_compressed'; +import { unicodeToUnifiedName } from './unicode_to_unified_name'; -const emojis = {}; +const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed; + +const emojis: Record = {}; // decompress Object.keys(shortCodesToEmojiData).forEach((shortCode) => { const [ - filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars + _filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars searchData, ] = shortCodesToEmojiData[shortCode]; const [ @@ -27,7 +29,14 @@ Object.keys(shortCodesToEmojiData).forEach((shortCode) => { }; }); -module.exports = { +export { + emojis, + skins, + categories, + short_names, +}; + +export default { emojis, skins, categories, diff --git a/app/soapbox/features/emoji/emoji_picker.js b/app/soapbox/features/emoji/emoji_picker.ts similarity index 100% rename from app/soapbox/features/emoji/emoji_picker.js rename to app/soapbox/features/emoji/emoji_picker.ts diff --git a/app/soapbox/features/filters/index.js b/app/soapbox/features/filters/index.js deleted file mode 100644 index 62e12cdf6..000000000 --- a/app/soapbox/features/filters/index.js +++ /dev/null @@ -1,257 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; -import snackbar from 'soapbox/actions/snackbar'; -import Icon from 'soapbox/components/icon'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui'; -import { - FieldsGroup, - Checkbox, -} from 'soapbox/features/forms'; - -const messages = defineMessages({ - heading: { id: 'column.filters', defaultMessage: 'Muted words' }, - subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, - keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, - expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, - expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' }, - home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, - public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, - notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, - conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, - drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, - drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, - whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' }, - whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' }, - add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, - create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, - delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, - subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, - delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, -}); - -// const expirations = { -// null: 'Never', -// // 3600: '30 minutes', -// // 21600: '1 hour', -// // 43200: '12 hours', -// // 86400 : '1 day', -// // 604800: '1 week', -// }; - -const mapStateToProps = state => ({ - filters: state.get('filters'), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Filters extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - phrase: '', - expires_at: '', - home_timeline: true, - public_timeline: false, - notifications: false, - conversations: false, - irreversible: false, - whole_word: true, - } - - - componentDidMount() { - this.props.dispatch(fetchFilters()); - } - - handleInputChange = e => { - this.setState({ [e.target.name]: e.target.value }); - } - - handleSelectChange = e => { - this.setState({ [e.target.name]: e.target.value }); - } - - handleCheckboxChange = e => { - this.setState({ [e.target.name]: e.target.checked }); - } - - handleAddNew = e => { - e.preventDefault(); - const { intl, dispatch } = this.props; - const { phrase, whole_word, expires_at, irreversible } = this.state; - const { home_timeline, public_timeline, notifications, conversations } = this.state; - const context = []; - - if (home_timeline) { - context.push('home'); - } - if (public_timeline) { - context.push('public'); - } - if (notifications) { - context.push('notifications'); - } - if (conversations) { - context.push('thread'); - } - - dispatch(createFilter(intl, phrase, expires_at, context, whole_word, irreversible)).then(response => { - return dispatch(fetchFilters()); - }).catch(error => { - dispatch(snackbar.error(intl.formatMessage(messages.create_error))); - }); - } - - handleFilterDelete = e => { - const { intl, dispatch } = this.props; - dispatch(deleteFilter(intl, e.currentTarget.dataset.value)).then(response => { - return dispatch(fetchFilters()); - }).catch(error => { - dispatch(snackbar.error(intl.formatMessage(messages.delete_error))); - }); - } - - - render() { - const { intl, filters } = this.props; - const emptyMessage = ; - - return ( - - - - -
- - - - {/* - - */} - - - - - - - - -
- - - - -
- -
- - - - - - - - - -
- - - - - - - {filters.map((filter, i) => ( -
-
-
- - {filter.get('phrase')} -
-
- - - {filter.get('context').map((context, i) => ( - {context} - ))} - -
-
- - - {filter.get('irreversible') ? - : - - } - {filter.get('whole_word') && - - } - -
-
-
- - -
-
- ))} -
-
- ); - } - -} diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx new file mode 100644 index 000000000..d71adb96e --- /dev/null +++ b/app/soapbox/features/filters/index.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; +import snackbar from 'soapbox/actions/snackbar'; +import Icon from 'soapbox/components/icon'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui'; +import { + FieldsGroup, + Checkbox, +} from 'soapbox/features/forms'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.filters', defaultMessage: 'Muted words' }, + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, + expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' }, + home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, + public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, + notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, + conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, + drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, + drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, + whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' }, + whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' }, + add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, + create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, + subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, + delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, +}); + +// const expirations = { +// null: 'Never', +// // 3600: '30 minutes', +// // 21600: '1 hour', +// // 43200: '12 hours', +// // 86400 : '1 day', +// // 604800: '1 week', +// }; + +const Filters = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const filters = useAppSelector((state) => state.filters); + + const [phrase, setPhrase] = useState(''); + const [expiresAt] = useState(''); + const [homeTimeline, setHomeTimeline] = useState(true); + const [publicTimeline, setPublicTimeline] = useState(false); + const [notifications, setNotifications] = useState(false); + const [conversations, setConversations] = useState(false); + const [irreversible, setIrreversible] = useState(false); + const [wholeWord, setWholeWord] = useState(true); + + // const handleSelectChange = e => { + // this.setState({ [e.target.name]: e.target.value }); + // }; + + const handleAddNew: React.FormEventHandler = e => { + e.preventDefault(); + const context = []; + + if (homeTimeline) { + context.push('home'); + } + if (publicTimeline) { + context.push('public'); + } + if (notifications) { + context.push('notifications'); + } + if (conversations) { + context.push('thread'); + } + + dispatch(createFilter(intl, phrase, expiresAt, context, wholeWord, irreversible)).then(() => { + return dispatch(fetchFilters()); + }).catch(error => { + dispatch(snackbar.error(intl.formatMessage(messages.create_error))); + }); + }; + + const handleFilterDelete: React.MouseEventHandler = e => { + dispatch(deleteFilter(intl, e.currentTarget.dataset.value)).then(() => { + return dispatch(fetchFilters()); + }).catch(() => { + dispatch(snackbar.error(intl.formatMessage(messages.delete_error))); + }); + }; + + useEffect(() => { + dispatch(fetchFilters()); + }, []); + + const emptyMessage = ; + + return ( + + + + +
+ + setPhrase(target.value)} + /> + + {/* + + */} + + + + + + + + +
+ setHomeTimeline(target.checked)} + /> + setPublicTimeline(target.checked)} + /> + setNotifications(target.checked)} + /> + setConversations(target.checked)} + /> +
+ +
+ + + setIrreversible(target.checked)} + /> + setWholeWord(target.checked)} + /> + + + + + +
+ + + + + + + {filters.map((filter, i) => ( +
+
+
+ + {filter.phrase} +
+
+ + + {filter.context.map((context, i) => ( + {context} + ))} + +
+
+ + + {filter.irreversible ? + : + + } + {filter.whole_word && + + } + +
+
+
+ + +
+
+ ))} +
+
+ ); +}; + +export default Filters; diff --git a/app/soapbox/normalizers/filter.ts b/app/soapbox/normalizers/filter.ts new file mode 100644 index 000000000..5f2f57960 --- /dev/null +++ b/app/soapbox/normalizers/filter.ts @@ -0,0 +1,22 @@ +/** + * Filter normalizer: + * Converts API filters into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/filter/} + */ +import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/filter/ +export const FilterRecord = ImmutableRecord({ + id: '', + phrase: '', + context: ImmutableList(), + whole_word: false, + expires_at: '', + irreversible: false, +}); + +export const normalizeFilter = (filter: Record) => { + return FilterRecord( + ImmutableMap(fromJS(filter)), + ); +}; \ No newline at end of file diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index d660df0f2..b1b41e0d6 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -6,6 +6,7 @@ export { CardRecord, normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; +export { FilterRecord, normalizeFilter } from './filter'; export { HistoryRecord, normalizeHistory } from './history'; export { InstanceRecord, normalizeInstance } from './instance'; export { ListRecord, normalizeList } from './list'; diff --git a/app/soapbox/reducers/custom_emojis.js b/app/soapbox/reducers/custom_emojis.ts similarity index 65% rename from app/soapbox/reducers/custom_emojis.js rename to app/soapbox/reducers/custom_emojis.ts index 7008d5234..477e7cce9 100644 --- a/app/soapbox/reducers/custom_emojis.js +++ b/app/soapbox/reducers/custom_emojis.ts @@ -1,4 +1,4 @@ -import { List as ImmutableList, fromJS } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable'; import { emojis as emojiData } from 'soapbox/features/emoji/emoji_mart_data_light'; import { addCustomToPool } from 'soapbox/features/emoji/emoji_mart_search_light'; @@ -6,15 +6,18 @@ import { addCustomToPool } from 'soapbox/features/emoji/emoji_mart_search_light' import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis'; import { buildCustomEmojis } from '../features/emoji/emoji'; +import type { AnyAction } from 'redux'; +import type { APIEntity } from 'soapbox/types/entities'; + const initialState = ImmutableList(); // Populate custom emojis for composer autosuggest -const autosuggestPopulate = emojis => { +const autosuggestPopulate = (emojis: ImmutableList>) => { addCustomToPool(buildCustomEmojis(emojis)); }; -const importEmojis = (state, customEmojis) => { - const emojis = fromJS(customEmojis).filter(emoji => { +const importEmojis = (customEmojis: APIEntity[]) => { + const emojis = (fromJS(customEmojis) as ImmutableList>).filter((emoji) => { // If a custom emoji has the shortcode of a Unicode emoji, skip it. // Otherwise it breaks EmojiMart. // https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/610 @@ -26,9 +29,9 @@ const importEmojis = (state, customEmojis) => { return emojis; }; -export default function custom_emojis(state = initialState, action) { +export default function custom_emojis(state = initialState, action: AnyAction) { if (action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) { - return importEmojis(state, action.custom_emojis); + return importEmojis(action.custom_emojis); } return state; diff --git a/app/soapbox/reducers/filters.ts b/app/soapbox/reducers/filters.ts new file mode 100644 index 000000000..a31cb3295 --- /dev/null +++ b/app/soapbox/reducers/filters.ts @@ -0,0 +1,23 @@ +import { List as ImmutableList } from 'immutable'; + +import { normalizeFilter } from 'soapbox/normalizers'; + +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; + +import type { AnyAction } from 'redux'; +import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; + +type State = ImmutableList; + +const importFilters = (_state: State, filters: APIEntity[]): State => { + return ImmutableList(filters.map((filter) => normalizeFilter(filter))); +}; + +export default function filters(state: State = ImmutableList(), action: AnyAction): State { + switch (action.type) { + case FILTERS_FETCH_SUCCESS: + return importFilters(state, action.filters); + default: + return state; + } +} diff --git a/app/soapbox/reducers/filters.tsx b/app/soapbox/reducers/filters.tsx deleted file mode 100644 index e79dde019..000000000 --- a/app/soapbox/reducers/filters.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { - Map as ImmutableMap, - List as ImmutableList, - fromJS, -} from 'immutable'; - -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; - -import type { AnyAction } from 'redux'; - -type Filter = ImmutableMap; -type State = ImmutableList; - -const importFilters = (_state: State, filters: unknown): State => { - return ImmutableList(fromJS(filters)).map(filter => ImmutableMap(fromJS(filter))); -}; - -export default function filters(state: State = ImmutableList(), action: AnyAction): State { - switch (action.type) { - case FILTERS_FETCH_SUCCESS: - return importFilters(state, action.filters); - default: - return state; - } -} diff --git a/app/soapbox/reducers/profile_hover_card.js b/app/soapbox/reducers/profile_hover_card.js deleted file mode 100644 index 8020b965f..000000000 --- a/app/soapbox/reducers/profile_hover_card.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { - PROFILE_HOVER_CARD_OPEN, - PROFILE_HOVER_CARD_CLOSE, - PROFILE_HOVER_CARD_UPDATE, -} from 'soapbox/actions/profile_hover_card'; - -const initialState = ImmutableMap(); - -export default function profileHoverCard(state = initialState, action) { - switch (action.type) { - case PROFILE_HOVER_CARD_OPEN: - return ImmutableMap({ - ref: action.ref, - accountId: action.accountId, - }); - case PROFILE_HOVER_CARD_UPDATE: - return state.set('hovered', true); - case PROFILE_HOVER_CARD_CLOSE: - if (state.get('hovered') === true && !action.force) - return state; - else - return ImmutableMap(); - default: - return state; - } -} diff --git a/app/soapbox/reducers/profile_hover_card.ts b/app/soapbox/reducers/profile_hover_card.ts new file mode 100644 index 000000000..b07897715 --- /dev/null +++ b/app/soapbox/reducers/profile_hover_card.ts @@ -0,0 +1,36 @@ +import { Record as ImmutableRecord } from 'immutable'; + +import { + PROFILE_HOVER_CARD_OPEN, + PROFILE_HOVER_CARD_CLOSE, + PROFILE_HOVER_CARD_UPDATE, +} from 'soapbox/actions/profile_hover_card'; + +import type { AnyAction } from 'redux'; + +const ReducerRecord = ImmutableRecord({ + ref: null as React.MutableRefObject | null, + accountId: '', + hovered: false, +}); + +type State = ReturnType; + +export default function profileHoverCard(state: State = ReducerRecord(), action: AnyAction) { + switch (action.type) { + case PROFILE_HOVER_CARD_OPEN: + return state.withMutations((state) => { + state.set('ref', action.ref); + state.set('accountId', action.accountId); + }); + case PROFILE_HOVER_CARD_UPDATE: + return state.set('hovered', true); + case PROFILE_HOVER_CARD_CLOSE: + if (state.get('hovered') === true && !action.force) + return state; + else + return ReducerRecord(); + default: + return state; + } +} diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index 316517a55..c1160acd3 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -14,7 +14,7 @@ import { shouldFilter } from 'soapbox/utils/timelines'; import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; -import type { Notification } from 'soapbox/types/entities'; +import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; @@ -104,18 +104,18 @@ const toServerSideType = (columnType: string): string => { type FilterContext = { contextType?: string }; export const getFilters = (state: RootState, query: FilterContext) => { - return state.filters.filter((filter): boolean => { + return state.filters.filter((filter) => { return query?.contextType - && filter.get('context').includes(toServerSideType(query.contextType)) - && (filter.get('expires_at') === null - || Date.parse(filter.get('expires_at')) > new Date().getTime()); + && filter.context.includes(toServerSideType(query.contextType)) + && (filter.expires_at === null + || Date.parse(filter.expires_at) > new Date().getTime()); }); }; const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -export const regexFromFilters = (filters: ImmutableList>) => { +export const regexFromFilters = (filters: ImmutableList) => { if (filters.size === 0) return null; return new RegExp(filters.map(filter => { diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 023139cef..37572ae24 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -8,6 +8,7 @@ import { ChatMessageRecord, EmojiRecord, FieldRecord, + FilterRecord, HistoryRecord, InstanceRecord, ListRecord, @@ -31,6 +32,7 @@ type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; type Field = ReturnType; +type Filter = ReturnType; type History = ReturnType; type Instance = ReturnType; type List = ReturnType; @@ -68,6 +70,7 @@ export { ChatMessage, Emoji, Field, + Filter, History, Instance, List,