diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index 8e5e281bd2..11de952f26 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 4756c1a5de..03bdbf765a 100644 Binary files a/app/soapbox/features/emoji/emoji_mart_data_light.js and b/app/soapbox/features/emoji/emoji_mart_data_light.ts differ 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 62e12cdf60..0000000000 Binary files a/app/soapbox/features/filters/index.js and /dev/null differ diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx new file mode 100644 index 0000000000..d71adb96e3 --- /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 0000000000..5f2f579603 --- /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 d660df0f2a..b1b41e0d65 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 7008d52343..477e7cce97 100644 Binary files a/app/soapbox/reducers/custom_emojis.js and b/app/soapbox/reducers/custom_emojis.ts differ diff --git a/app/soapbox/reducers/filters.ts b/app/soapbox/reducers/filters.ts new file mode 100644 index 0000000000..a31cb3295e --- /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 e79dde0196..0000000000 --- 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 8020b965f2..0000000000 Binary files a/app/soapbox/reducers/profile_hover_card.js and /dev/null differ diff --git a/app/soapbox/reducers/profile_hover_card.ts b/app/soapbox/reducers/profile_hover_card.ts new file mode 100644 index 0000000000..b07897715c --- /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 316517a559..c1160acd3a 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 023139cefa..37572ae249 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,