From bdf9baa2e82a3e363b73990e24697c3a321feeae Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 18:06:23 +0200 Subject: [PATCH 1/6] Refactor `` into TypeScript (#34355) --- app/javascript/mastodon/actions/tags.js | 81 --------- app/javascript/mastodon/api/tags.ts | 14 +- .../mastodon/components/hashtag.tsx | 4 + .../mastodon/features/followed_tags/index.jsx | 95 ----------- .../mastodon/features/followed_tags/index.tsx | 161 ++++++++++++++++++ .../mastodon/reducers/followed_tags.js | 43 ----- app/javascript/mastodon/reducers/index.ts | 2 - .../styles/mastodon/components.scss | 10 +- 8 files changed, 181 insertions(+), 229 deletions(-) delete mode 100644 app/javascript/mastodon/actions/tags.js delete mode 100644 app/javascript/mastodon/features/followed_tags/index.jsx create mode 100644 app/javascript/mastodon/features/followed_tags/index.tsx delete mode 100644 app/javascript/mastodon/reducers/followed_tags.js diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js deleted file mode 100644 index 6e0c95288a..0000000000 --- a/app/javascript/mastodon/actions/tags.js +++ /dev/null @@ -1,81 +0,0 @@ -import api, { getLinks } from '../api'; - -export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; -export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; -export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; - -export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; -export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; -export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; - -export const fetchFollowedHashtags = () => (dispatch) => { - dispatch(fetchFollowedHashtagsRequest()); - - api().get('/api/v1/followed_tags').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); - }).catch(err => { - dispatch(fetchFollowedHashtagsFail(err)); - }); -}; - -export function fetchFollowedHashtagsRequest() { - return { - type: FOLLOWED_HASHTAGS_FETCH_REQUEST, - }; -} - -export function fetchFollowedHashtagsSuccess(followed_tags, next) { - return { - type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, - followed_tags, - next, - }; -} - -export function fetchFollowedHashtagsFail(error) { - return { - type: FOLLOWED_HASHTAGS_FETCH_FAIL, - error, - }; -} - -export function expandFollowedHashtags() { - return (dispatch, getState) => { - const url = getState().getIn(['followed_tags', 'next']); - - if (url === null) { - return; - } - - dispatch(expandFollowedHashtagsRequest()); - - api().get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandFollowedHashtagsFail(error)); - }); - }; -} - -export function expandFollowedHashtagsRequest() { - return { - type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, - }; -} - -export function expandFollowedHashtagsSuccess(followed_tags, next) { - return { - type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, - followed_tags, - next, - }; -} - -export function expandFollowedHashtagsFail(error) { - return { - type: FOLLOWED_HASHTAGS_EXPAND_FAIL, - error, - }; -} diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts index 2cb802800c..4b111def81 100644 --- a/app/javascript/mastodon/api/tags.ts +++ b/app/javascript/mastodon/api/tags.ts @@ -1,4 +1,4 @@ -import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; export const apiGetTag = (tagId: string) => @@ -9,3 +9,15 @@ export const apiFollowTag = (tagId: string) => export const apiUnfollowTag = (tagId: string) => apiRequestPost(`v1/tags/${tagId}/unfollow`); + +export const apiGetFollowedTags = async (url?: string) => { + const response = await api().request({ + method: 'GET', + url: url ?? '/api/v1/followed_tags', + }); + + return { + tags: response.data, + links: getLinks(response), + }; +}; diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 30c20e0abd..1fe41e1e8b 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -106,6 +106,7 @@ export interface HashtagProps { to: string; uses?: number; withGraph?: boolean; + children?: React.ReactNode; } export const Hashtag: React.FC = ({ @@ -117,6 +118,7 @@ export const Hashtag: React.FC = ({ className, description, withGraph = true, + children, }) => (
@@ -158,5 +160,7 @@ export const Hashtag: React.FC = ({
)} + + {children &&
{children}
}
); diff --git a/app/javascript/mastodon/features/followed_tags/index.jsx b/app/javascript/mastodon/features/followed_tags/index.jsx deleted file mode 100644 index 21248e6de9..0000000000 --- a/app/javascript/mastodon/features/followed_tags/index.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags'; -import ColumnHeader from 'mastodon/components/column_header'; -import { Hashtag } from 'mastodon/components/hashtag'; -import ScrollableList from 'mastodon/components/scrollable_list'; -import Column from 'mastodon/features/ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, -}); - -const mapStateToProps = state => ({ - hashtags: state.getIn(['followed_tags', 'items']), - isLoading: state.getIn(['followed_tags', 'isLoading'], true), - hasMore: !!state.getIn(['followed_tags', 'next']), -}); - -class FollowedTags extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hashtags: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - multiColumn: PropTypes.bool, - }; - - componentDidMount() { - this.props.dispatch(fetchFollowedHashtags()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFollowedHashtags()); - }, 300, { leading: true }); - - render () { - const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props; - - const emptyMessage = ; - - return ( - - - - - {hashtags.map((hashtag) => ( - day.get('uses')).toArray()} - /> - ))} - - - - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(FollowedTags)); diff --git a/app/javascript/mastodon/features/followed_tags/index.tsx b/app/javascript/mastodon/features/followed_tags/index.tsx new file mode 100644 index 0000000000..21d63a6fec --- /dev/null +++ b/app/javascript/mastodon/features/followed_tags/index.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import TagIcon from '@/material-icons/400-24px/tag.svg?react'; +import { unfollowHashtag } from 'mastodon/actions/tags_typed'; +import { apiGetFollowedTags } from 'mastodon/api/tags'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import { Button } from 'mastodon/components/button'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Hashtag } from 'mastodon/components/hashtag'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import { useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const FollowedTag: React.FC<{ + tag: ApiHashtagJSON; + onUnfollow: (arg0: string) => void; +}> = ({ tag, onUnfollow }) => { + const dispatch = useAppDispatch(); + const tagId = tag.name; + + const handleClick = useCallback(() => { + void dispatch(unfollowHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + onUnfollow(tagId); + } + + return ''; + }); + }, [dispatch, onUnfollow, tagId]); + + const people = + parseInt(tag.history[0].accounts) + + parseInt(tag.history[1]?.accounts ?? ''); + + return ( + + + + ); +}; + +const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const intl = useIntl(); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [next, setNext] = useState(); + const hasMore = !!next; + const columnRef = useRef(null); + + useEffect(() => { + setLoading(true); + + void apiGetFollowedTags() + .then(({ tags, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setTags(tags); + setLoading(false); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setTags, setLoading, setNext]); + + const handleLoadMore = useCallback(() => { + setLoading(true); + + void apiGetFollowedTags(next) + .then(({ tags, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setLoading(false); + setTags((previousTags) => [...previousTags, ...tags]); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setTags, setLoading, setNext, next]); + + const handleUnfollow = useCallback( + (tagId: string) => { + setTags((tags) => tags.filter((tag) => tag.name !== tagId)); + }, + [setTags], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const emptyMessage = ( + + ); + + return ( + + + + + {tags.map((tag) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default FollowedTags; diff --git a/app/javascript/mastodon/reducers/followed_tags.js b/app/javascript/mastodon/reducers/followed_tags.js deleted file mode 100644 index afea8e3b35..0000000000 --- a/app/javascript/mastodon/reducers/followed_tags.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; - -import { - FOLLOWED_HASHTAGS_FETCH_REQUEST, - FOLLOWED_HASHTAGS_FETCH_SUCCESS, - FOLLOWED_HASHTAGS_FETCH_FAIL, - FOLLOWED_HASHTAGS_EXPAND_REQUEST, - FOLLOWED_HASHTAGS_EXPAND_SUCCESS, - FOLLOWED_HASHTAGS_EXPAND_FAIL, -} from 'mastodon/actions/tags'; - -const initialState = ImmutableMap({ - items: ImmutableList(), - isLoading: false, - next: null, -}); - -export default function followed_tags(state = initialState, action) { - switch(action.type) { - case FOLLOWED_HASHTAGS_FETCH_REQUEST: - return state.set('isLoading', true); - case FOLLOWED_HASHTAGS_FETCH_SUCCESS: - return state.withMutations(map => { - map.set('items', fromJS(action.followed_tags)); - map.set('isLoading', false); - map.set('next', action.next); - }); - case FOLLOWED_HASHTAGS_FETCH_FAIL: - return state.set('isLoading', false); - case FOLLOWED_HASHTAGS_EXPAND_REQUEST: - return state.set('isLoading', true); - case FOLLOWED_HASHTAGS_EXPAND_SUCCESS: - return state.withMutations(map => { - map.update('items', set => set.concat(fromJS(action.followed_tags))); - map.set('isLoading', false); - map.set('next', action.next); - }); - case FOLLOWED_HASHTAGS_EXPAND_FAIL: - return state.set('isLoading', false); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index cd5f55a868..e98d835f47 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -13,7 +13,6 @@ import conversations from './conversations'; import custom_emojis from './custom_emojis'; import { dropdownMenuReducer } from './dropdown_menu'; import filters from './filters'; -import followed_tags from './followed_tags'; import height_cache from './height_cache'; import history from './history'; import { listsReducer } from './lists'; @@ -73,7 +72,6 @@ const reducers = { markers: markersReducer, picture_in_picture: pictureInPictureReducer, history, - followed_tags, notificationPolicy: notificationPolicyReducer, notificationRequests: notificationRequestsReducer, }; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a399553f29..5d011d8f32 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -8110,13 +8110,9 @@ noscript { &__item { display: flex; align-items: center; - padding: 15px; + padding: 16px; border-bottom: 1px solid var(--background-border-color); - gap: 15px; - - &:last-child { - border-bottom: 0; - } + gap: 8px; &__name { flex: 1 1 auto; @@ -8223,7 +8219,7 @@ noscript { } &--compact &__item { - padding: 10px; + padding: 12px; } } From 6a39f00745ab2da7917f9584f94ef1eb456001c4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 18:06:31 +0200 Subject: [PATCH 2/6] Refactor `` and `` into TypeScript (#34356) --- .../features/bookmarked_statuses/index.jsx | 115 ----------------- .../features/bookmarked_statuses/index.tsx | 116 ++++++++++++++++++ .../features/favourited_statuses/index.jsx | 115 ----------------- .../features/favourited_statuses/index.tsx | 116 ++++++++++++++++++ .../mastodon/reducers/status_lists.js | 1 + app/javascript/mastodon/selectors/index.js | 5 +- app/javascript/mastodon/selectors/statuses.ts | 15 +++ 7 files changed, 249 insertions(+), 234 deletions(-) delete mode 100644 app/javascript/mastodon/features/bookmarked_statuses/index.jsx create mode 100644 app/javascript/mastodon/features/bookmarked_statuses/index.tsx delete mode 100644 app/javascript/mastodon/features/favourited_statuses/index.jsx create mode 100644 app/javascript/mastodon/features/favourited_statuses/index.tsx create mode 100644 app/javascript/mastodon/selectors/statuses.ts diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx deleted file mode 100644 index 122baafd6c..0000000000 --- a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'mastodon/actions/bookmarks'; -import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; -import ColumnHeader from 'mastodon/components/column_header'; -import StatusList from 'mastodon/components/status_list'; -import Column from 'mastodon/features/ui/components/column'; -import { getStatusList } from 'mastodon/selectors'; - -const messages = defineMessages({ - heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, -}); - -const mapStateToProps = state => ({ - statusIds: getStatusList(state, 'bookmarks'), - isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), -}); - -class Bookmarks extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - UNSAFE_componentWillMount () { - this.props.dispatch(fetchBookmarkedStatuses()); - } - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('BOOKMARKS', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBookmarkedStatuses()); - }, 300, { leading: true }); - - render () { - const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - - - {intl.formatMessage(messages.heading)} - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Bookmarks)); diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.tsx b/app/javascript/mastodon/features/bookmarked_statuses/index.tsx new file mode 100644 index 0000000000..5d4574b05b --- /dev/null +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; +import { + fetchBookmarkedStatuses, + expandBookmarkedStatuses, +} from 'mastodon/actions/bookmarks'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import StatusList from 'mastodon/components/status_list'; +import { getStatusList } from 'mastodon/selectors'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const Bookmarks: React.FC<{ + columnId: string; + multiColumn: boolean; +}> = ({ columnId, multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const columnRef = useRef(null); + const statusIds = useAppSelector((state) => + getStatusList(state, 'bookmarks'), + ); + const isLoading = useAppSelector( + (state) => + state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean, + ); + const hasMore = useAppSelector( + (state) => !!state.status_lists.getIn(['bookmarks', 'next']), + ); + + useEffect(() => { + dispatch(fetchBookmarkedStatuses()); + }, [dispatch]); + + const handlePin = useCallback(() => { + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + }, [dispatch, columnId]); + + const handleMove = useCallback( + (dir: number) => { + dispatch(moveColumn(columnId, dir)); + }, + [dispatch, columnId], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleLoadMore = useCallback(() => { + dispatch(expandBookmarkedStatuses()); + }, [dispatch]); + + const pinned = !!columnId; + + const emptyMessage = ( + + ); + + return ( + + + + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Bookmarks; diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx deleted file mode 100644 index 9e0b982239..0000000000 --- a/app/javascript/mastodon/features/favourited_statuses/index.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; -import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; -import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/actions/favourites'; -import ColumnHeader from 'mastodon/components/column_header'; -import StatusList from 'mastodon/components/status_list'; -import Column from 'mastodon/features/ui/components/column'; -import { getStatusList } from 'mastodon/selectors'; - -const messages = defineMessages({ - heading: { id: 'column.favourites', defaultMessage: 'Favorites' }, -}); - -const mapStateToProps = state => ({ - statusIds: getStatusList(state, 'favourites'), - isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), -}); - -class Favourites extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - UNSAFE_componentWillMount () { - this.props.dispatch(fetchFavouritedStatuses()); - } - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('FAVOURITES', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFavouritedStatuses()); - }, 300, { leading: true }); - - render () { - const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - - - {intl.formatMessage(messages.heading)} - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.tsx b/app/javascript/mastodon/features/favourited_statuses/index.tsx new file mode 100644 index 0000000000..908a8ae4a1 --- /dev/null +++ b/app/javascript/mastodon/features/favourited_statuses/index.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { + fetchFavouritedStatuses, + expandFavouritedStatuses, +} from 'mastodon/actions/favourites'; +import { Column } from 'mastodon/components/column'; +import type { ColumnRef } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import StatusList from 'mastodon/components/status_list'; +import { getStatusList } from 'mastodon/selectors'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favorites' }, +}); + +const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({ + columnId, + multiColumn, +}) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const columnRef = useRef(null); + const statusIds = useAppSelector((state) => + getStatusList(state, 'favourites'), + ); + const isLoading = useAppSelector( + (state) => + state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean, + ); + const hasMore = useAppSelector( + (state) => !!state.status_lists.getIn(['favourites', 'next']), + ); + + useEffect(() => { + dispatch(fetchFavouritedStatuses()); + }, [dispatch]); + + const handlePin = useCallback(() => { + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + }, [dispatch, columnId]); + + const handleMove = useCallback( + (dir: number) => { + dispatch(moveColumn(columnId, dir)); + }, + [dispatch, columnId], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleLoadMore = useCallback(() => { + dispatch(expandFavouritedStatuses()); + }, [dispatch]); + + const pinned = !!columnId; + + const emptyMessage = ( + + ); + + return ( + + + + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Favourites; diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 6cb6a937bb..c9d39130ee 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -96,6 +96,7 @@ const removeOneFromList = (state, listType, status) => { return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id'))); }; +/** @type {import('@reduxjs/toolkit').Reducer} */ export default function statusLists(state = initialState, action) { switch(action.type) { case FAVOURITED_STATUSES_FETCH_REQUEST: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 5ccaba23fd..3119b285b2 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -6,6 +6,7 @@ import { me } from '../initial_state'; import { getFilters } from './filters'; export { makeGetAccount } from "./accounts"; +export { getStatusList } from "./statuses"; export const makeGetStatus = () => { return createSelector( @@ -77,7 +78,3 @@ export const makeGetReport = () => createSelector([ (_, base) => base, (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), ], (base, targetAccount) => base.set('target_account', targetAccount)); - -export const getStatusList = createSelector([ - (state, type) => state.getIn(['status_lists', type, 'items']), -], (items) => items.toList()); diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts new file mode 100644 index 0000000000..4d045e924a --- /dev/null +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -0,0 +1,15 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import type { RootState } from 'mastodon/store'; + +export const getStatusList = createSelector( + [ + ( + state: RootState, + type: 'favourites' | 'bookmarks' | 'pins' | 'trending', + ) => + state.status_lists.getIn([type, 'items']) as ImmutableOrderedSet, + ], + (items) => items.toList(), +); From 0e5be63fb327403902076df602ec2dd410ada789 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 18:28:14 +0200 Subject: [PATCH 3/6] Change unfollow button label from "Mutual" to "Unfollow" in web UI (#34392) --- .../mastodon/components/follow_button.tsx | 7 +- .../components/account_header.tsx | 99 +++---------------- 2 files changed, 15 insertions(+), 91 deletions(-) diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index f49abfd2b3..4a22bb1c3f 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -16,8 +16,7 @@ const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, - mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); export const FollowButton: React.FC<{ @@ -73,11 +72,9 @@ export const FollowButton: React.FC<{ if (!signedIn) { label = intl.formatMessage(messages.follow); } else if (accountId === me) { - label = intl.formatMessage(messages.edit_profile); + label = intl.formatMessage(messages.editProfile); } else if (!relationship) { label = ; - } else if (relationship.following && relationship.followed_by) { - label = intl.formatMessage(messages.mutual); } else if (relationship.following || relationship.requested) { label = intl.formatMessage(messages.unfollow); } else if (relationship.followed_by) { diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 9505d48010..ae1724a728 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -37,10 +37,10 @@ import { FollowingCounter, StatusesCounter, } from 'mastodon/components/counters'; +import { FollowButton } from 'mastodon/components/follow_button'; import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { ShortNumber } from 'mastodon/components/short_number'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { DomainPill } from 'mastodon/features/account/components/domain_pill'; @@ -51,7 +51,6 @@ import { useIdentity } from 'mastodon/identity_context'; import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; import type { DropdownMenu } from 'mastodon/models/dropdown_menu'; -import type { Relationship } from 'mastodon/models/relationship'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION, @@ -179,20 +178,6 @@ const titleFromAccount = (account: Account) => { return `${prefix} (@${acct})`; }; -const messageForFollowButton = (relationship?: Relationship) => { - if (!relationship) return messages.follow; - - if (relationship.get('following') && relationship.get('followed_by')) { - return messages.mutual; - } else if (relationship.get('following') || relationship.get('requested')) { - return messages.unfollow; - } else if (relationship.get('followed_by')) { - return messages.followBack; - } else { - return messages.follow; - } -}; - const dateFormatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', @@ -215,20 +200,6 @@ export const AccountHeader: React.FC<{ const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); const handleLinkClick = useLinks(); - const handleFollow = useCallback(() => { - if (!account) { - return; - } - - if (relationship?.following || relationship?.requested) { - dispatch( - openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), - ); - } else { - dispatch(followAccount(account.id)); - } - }, [dispatch, account, relationship]); - const handleBlock = useCallback(() => { if (!account) { return; @@ -365,23 +336,6 @@ export const AccountHeader: React.FC<{ ); }, [dispatch, account]); - const handleInteractionModal = useCallback(() => { - if (!account) { - return; - } - - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'follow', - accountId: account.id, - url: account.uri, - }, - }), - ); - }, [dispatch, account]); - const handleOpenAvatar = useCallback( (e: React.MouseEvent) => { if (e.button !== 0 || e.ctrlKey || e.metaKey) { @@ -417,10 +371,6 @@ export const AccountHeader: React.FC<{ }); }, [account]); - const handleEditProfile = useCallback(() => { - window.open('/settings/profile', '_blank'); - }, []); - const handleMouseEnter = useCallback( ({ currentTarget }: React.MouseEvent) => { if (autoPlayGif) { @@ -680,9 +630,12 @@ export const AccountHeader: React.FC<{ return null; } - let actionBtn, bellBtn, lockedIcon, shareBtn; + let actionBtn: React.ReactNode, + bellBtn: React.ReactNode, + lockedIcon: React.ReactNode, + shareBtn: React.ReactNode; - const info = []; + const info: React.ReactNode[] = []; if (me !== account.id && relationship?.blocking) { info.push( @@ -750,43 +703,17 @@ export const AccountHeader: React.FC<{ ); } - if (me !== account.id) { - if (signedIn && !relationship) { - // Wait until the relationship is loaded - actionBtn = ( - - ); - } else if (!relationship?.blocking) { - actionBtn = ( - + + ); +}; + +const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const intl = useIntl(); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [next, setNext] = useState(); + const hasMore = !!next; + const columnRef = useRef(null); + + useEffect(() => { + setLoading(true); + + void apiGetFollowedTags() + .then(({ tags, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setTags(tags); + setLoading(false); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setTags, setLoading, setNext]); + + const handleLoadMore = useCallback(() => { + setLoading(true); + + void apiGetFollowedTags(next) + .then(({ tags, links }) => { + const next = links.refs.find((link) => link.rel === 'next'); + + setLoading(false); + setTags((previousTags) => [...previousTags, ...tags]); + setNext(next?.uri); + + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setTags, setLoading, setNext, next]); + + const handleUnfollow = useCallback( + (tagId: string) => { + setTags((tags) => tags.filter((tag) => tag.name !== tagId)); + }, + [setTags], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const emptyMessage = ( + + ); + + return ( + + + + + {tags.map((tag) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default FollowedTags; diff --git a/app/javascript/flavours/glitch/reducers/followed_tags.js b/app/javascript/flavours/glitch/reducers/followed_tags.js deleted file mode 100644 index f2281463a4..0000000000 --- a/app/javascript/flavours/glitch/reducers/followed_tags.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; - -import { - FOLLOWED_HASHTAGS_FETCH_REQUEST, - FOLLOWED_HASHTAGS_FETCH_SUCCESS, - FOLLOWED_HASHTAGS_FETCH_FAIL, - FOLLOWED_HASHTAGS_EXPAND_REQUEST, - FOLLOWED_HASHTAGS_EXPAND_SUCCESS, - FOLLOWED_HASHTAGS_EXPAND_FAIL, -} from 'flavours/glitch/actions/tags'; - -const initialState = ImmutableMap({ - items: ImmutableList(), - isLoading: false, - next: null, -}); - -export default function followed_tags(state = initialState, action) { - switch(action.type) { - case FOLLOWED_HASHTAGS_FETCH_REQUEST: - return state.set('isLoading', true); - case FOLLOWED_HASHTAGS_FETCH_SUCCESS: - return state.withMutations(map => { - map.set('items', fromJS(action.followed_tags)); - map.set('isLoading', false); - map.set('next', action.next); - }); - case FOLLOWED_HASHTAGS_FETCH_FAIL: - return state.set('isLoading', false); - case FOLLOWED_HASHTAGS_EXPAND_REQUEST: - return state.set('isLoading', true); - case FOLLOWED_HASHTAGS_EXPAND_SUCCESS: - return state.withMutations(map => { - map.update('items', set => set.concat(fromJS(action.followed_tags))); - map.set('isLoading', false); - map.set('next', action.next); - }); - case FOLLOWED_HASHTAGS_EXPAND_FAIL: - return state.set('isLoading', false); - default: - return state; - } -} diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index 6ffed10248..33ed4ec967 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -13,7 +13,6 @@ import conversations from './conversations'; import custom_emojis from './custom_emojis'; import { dropdownMenuReducer } from './dropdown_menu'; import filters from './filters'; -import followed_tags from './followed_tags'; import height_cache from './height_cache'; import history from './history'; import { listsReducer } from './lists'; @@ -75,7 +74,6 @@ const reducers = { markers: markersReducer, picture_in_picture: pictureInPictureReducer, history, - followed_tags, notificationPolicy: notificationPolicyReducer, notificationRequests: notificationRequestsReducer, }; diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 3f60dfd78d..f1d113a593 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -8524,13 +8524,9 @@ noscript { &__item { display: flex; align-items: center; - padding: 15px; + padding: 16px; border-bottom: 1px solid var(--background-border-color); - gap: 15px; - - &:last-child { - border-bottom: 0; - } + gap: 8px; &__name { flex: 1 1 auto; @@ -8637,7 +8633,7 @@ noscript { } &--compact &__item { - padding: 10px; + padding: 12px; } } From 5e5dbf93750e01d9bf2778bb2e89a9bb2227c188 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 18:06:31 +0200 Subject: [PATCH 5/6] [Glitch] Refactor `` and `` into TypeScript Port 6a39f00745ab2da7917f9584f94ef1eb456001c4 to glitch-soc Signed-off-by: Claire --- .../features/bookmarked_statuses/index.jsx | 116 ----------------- .../features/bookmarked_statuses/index.tsx | 121 ++++++++++++++++++ .../features/favourited_statuses/index.jsx | 116 ----------------- .../features/favourited_statuses/index.tsx | 121 ++++++++++++++++++ .../flavours/glitch/reducers/status_lists.js | 1 + .../flavours/glitch/selectors/index.js | 5 +- .../flavours/glitch/selectors/statuses.ts | 15 +++ 7 files changed, 259 insertions(+), 236 deletions(-) delete mode 100644 app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx create mode 100644 app/javascript/flavours/glitch/features/bookmarked_statuses/index.tsx delete mode 100644 app/javascript/flavours/glitch/features/favourited_statuses/index.jsx create mode 100644 app/javascript/flavours/glitch/features/favourited_statuses/index.tsx create mode 100644 app/javascript/flavours/glitch/selectors/statuses.ts diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx deleted file mode 100644 index 49a5a73e1a..0000000000 --- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks'; -import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; -import ColumnHeader from 'flavours/glitch/components/column_header'; -import StatusList from 'flavours/glitch/components/status_list'; -import Column from 'flavours/glitch/features/ui/components/column'; -import { getStatusList } from 'flavours/glitch/selectors'; - -const messages = defineMessages({ - heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, -}); - -const mapStateToProps = state => ({ - statusIds: getStatusList(state, 'bookmarks'), - isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), -}); - -class Bookmarks extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - UNSAFE_componentWillMount () { - this.props.dispatch(fetchBookmarkedStatuses()); - } - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('BOOKMARKS', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBookmarkedStatuses()); - }, 300, { leading: true }); - - render () { - const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - - - {intl.formatMessage(messages.heading)} - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Bookmarks)); diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.tsx b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.tsx new file mode 100644 index 0000000000..a2a2bd790b --- /dev/null +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; +import { + fetchBookmarkedStatuses, + expandBookmarkedStatuses, +} from 'flavours/glitch/actions/bookmarks'; +import { + addColumn, + removeColumn, + moveColumn, +} from 'flavours/glitch/actions/columns'; +import { Column } from 'flavours/glitch/components/column'; +import type { ColumnRef } from 'flavours/glitch/components/column'; +import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import StatusList from 'flavours/glitch/components/status_list'; +import { getStatusList } from 'flavours/glitch/selectors'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const Bookmarks: React.FC<{ + columnId: string; + multiColumn: boolean; +}> = ({ columnId, multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const columnRef = useRef(null); + const statusIds = useAppSelector((state) => + getStatusList(state, 'bookmarks'), + ); + const isLoading = useAppSelector( + (state) => + state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean, + ); + const hasMore = useAppSelector( + (state) => !!state.status_lists.getIn(['bookmarks', 'next']), + ); + + useEffect(() => { + dispatch(fetchBookmarkedStatuses()); + }, [dispatch]); + + const handlePin = useCallback(() => { + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + }, [dispatch, columnId]); + + const handleMove = useCallback( + (dir: number) => { + dispatch(moveColumn(columnId, dir)); + }, + [dispatch, columnId], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleLoadMore = useCallback(() => { + dispatch(expandBookmarkedStatuses()); + }, [dispatch]); + + const pinned = !!columnId; + + const emptyMessage = ( + + ); + + return ( + + + + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Bookmarks; diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx b/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx deleted file mode 100644 index ce3624a54b..0000000000 --- a/app/javascript/flavours/glitch/features/favourited_statuses/index.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; -import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; -import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites'; -import ColumnHeader from 'flavours/glitch/components/column_header'; -import StatusList from 'flavours/glitch/components/status_list'; -import Column from 'flavours/glitch/features/ui/components/column'; -import { getStatusList } from 'flavours/glitch/selectors'; - -const messages = defineMessages({ - heading: { id: 'column.favourites', defaultMessage: 'Favorites' }, -}); - -const mapStateToProps = state => ({ - statusIds: getStatusList(state, 'favourites'), - isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), -}); - -class Favourites extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.list.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - UNSAFE_componentWillMount () { - this.props.dispatch(fetchFavouritedStatuses()); - } - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('FAVOURITES', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setRef = c => { - this.column = c; - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFavouritedStatuses()); - }, 300, { leading: true }); - - render () { - const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - - - {intl.formatMessage(messages.heading)} - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.tsx b/app/javascript/flavours/glitch/features/favourited_statuses/index.tsx new file mode 100644 index 0000000000..1f0b875944 --- /dev/null +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; +import { + addColumn, + removeColumn, + moveColumn, +} from 'flavours/glitch/actions/columns'; +import { + fetchFavouritedStatuses, + expandFavouritedStatuses, +} from 'flavours/glitch/actions/favourites'; +import { Column } from 'flavours/glitch/components/column'; +import type { ColumnRef } from 'flavours/glitch/components/column'; +import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import StatusList from 'flavours/glitch/components/status_list'; +import { getStatusList } from 'flavours/glitch/selectors'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favorites' }, +}); + +const Favourites: React.FC<{ columnId: string; multiColumn: boolean }> = ({ + columnId, + multiColumn, +}) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const columnRef = useRef(null); + const statusIds = useAppSelector((state) => + getStatusList(state, 'favourites'), + ); + const isLoading = useAppSelector( + (state) => + state.status_lists.getIn(['favourites', 'isLoading'], true) as boolean, + ); + const hasMore = useAppSelector( + (state) => !!state.status_lists.getIn(['favourites', 'next']), + ); + + useEffect(() => { + dispatch(fetchFavouritedStatuses()); + }, [dispatch]); + + const handlePin = useCallback(() => { + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + }, [dispatch, columnId]); + + const handleMove = useCallback( + (dir: number) => { + dispatch(moveColumn(columnId, dir)); + }, + [dispatch, columnId], + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleLoadMore = useCallback(() => { + dispatch(expandFavouritedStatuses()); + }, [dispatch]); + + const pinned = !!columnId; + + const emptyMessage = ( + + ); + + return ( + + + + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Favourites; diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js index 6cb6a937bb..c9d39130ee 100644 --- a/app/javascript/flavours/glitch/reducers/status_lists.js +++ b/app/javascript/flavours/glitch/reducers/status_lists.js @@ -96,6 +96,7 @@ const removeOneFromList = (state, listType, status) => { return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id'))); }; +/** @type {import('@reduxjs/toolkit').Reducer} */ export default function statusLists(state = initialState, action) { switch(action.type) { case FAVOURITED_STATUSES_FETCH_REQUEST: diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 6bc8121e2c..de05e4ec4d 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -6,6 +6,7 @@ import { me } from '../initial_state'; import { getFilters } from './filters'; export { makeGetAccount } from "./accounts"; +export { getStatusList } from "./statuses"; export const makeGetStatus = () => { return createSelector( @@ -78,7 +79,3 @@ export const makeGetReport = () => createSelector([ (_, base) => base, (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), ], (base, targetAccount) => base.set('target_account', targetAccount)); - -export const getStatusList = createSelector([ - (state, type) => state.getIn(['status_lists', type, 'items']), -], (items) => items.toList()); diff --git a/app/javascript/flavours/glitch/selectors/statuses.ts b/app/javascript/flavours/glitch/selectors/statuses.ts new file mode 100644 index 0000000000..96ea36dc1b --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/statuses.ts @@ -0,0 +1,15 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import type { RootState } from 'flavours/glitch/store'; + +export const getStatusList = createSelector( + [ + ( + state: RootState, + type: 'favourites' | 'bookmarks' | 'pins' | 'trending', + ) => + state.status_lists.getIn([type, 'items']) as ImmutableOrderedSet, + ], + (items) => items.toList(), +); From 861a5063f1bb6e796cf4155567baa98821731b2c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Apr 2025 18:28:14 +0200 Subject: [PATCH 6/6] [Glitch] Change unfollow button label from "Mutual" to "Unfollow" in web UI Port 0e5be63fb327403902076df602ec2dd410ada789 to glitch-soc Signed-off-by: Claire --- .../components/account_header.tsx | 99 +++---------------- 1 file changed, 13 insertions(+), 86 deletions(-) diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx index ed0299f94d..da4f6af829 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx @@ -36,10 +36,10 @@ import { } from 'flavours/glitch/components/badge'; import { Button } from 'flavours/glitch/components/button'; import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button'; +import { FollowButton } from 'flavours/glitch/components/follow_button'; import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date'; import { Icon } from 'flavours/glitch/components/icon'; import { IconButton } from 'flavours/glitch/components/icon_button'; -import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill'; import AccountNoteContainer from 'flavours/glitch/features/account/containers/account_note_container'; @@ -53,7 +53,6 @@ import { } from 'flavours/glitch/initial_state'; import type { Account } from 'flavours/glitch/models/account'; import type { DropdownMenu } from 'flavours/glitch/models/dropdown_menu'; -import type { Relationship } from 'flavours/glitch/models/relationship'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION, @@ -183,20 +182,6 @@ const titleFromAccount = (account: Account) => { return `${prefix} (@${acct})`; }; -const messageForFollowButton = (relationship?: Relationship) => { - if (!relationship) return messages.follow; - - if (relationship.get('requested')) { - return messages.cancel_follow_request; - } else if (relationship.get('following')) { - return messages.unfollow; - } else if (relationship.get('followed_by')) { - return messages.followBack; - } else { - return messages.follow; - } -}; - const dateFormatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', @@ -219,20 +204,6 @@ export const AccountHeader: React.FC<{ const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); const handleLinkClick = useLinks(); - const handleFollow = useCallback(() => { - if (!account) { - return; - } - - if (relationship?.following || relationship?.requested) { - dispatch( - openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), - ); - } else { - dispatch(followAccount(account.id)); - } - }, [dispatch, account, relationship]); - const handleBlock = useCallback(() => { if (!account) { return; @@ -369,23 +340,6 @@ export const AccountHeader: React.FC<{ ); }, [dispatch, account]); - const handleInteractionModal = useCallback(() => { - if (!account) { - return; - } - - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'follow', - accountId: account.id, - url: account.uri, - }, - }), - ); - }, [dispatch, account]); - const handleOpenAvatar = useCallback( (e: React.MouseEvent) => { if (e.button !== 0 || e.ctrlKey || e.metaKey) { @@ -421,10 +375,6 @@ export const AccountHeader: React.FC<{ }); }, [account]); - const handleEditProfile = useCallback(() => { - window.open('/settings/profile', '_blank'); - }, []); - const handleMouseEnter = useCallback( ({ currentTarget }: React.MouseEvent) => { if (autoPlayGif) { @@ -684,9 +634,12 @@ export const AccountHeader: React.FC<{ return null; } - let actionBtn, bellBtn, lockedIcon, shareBtn; + let actionBtn: React.ReactNode, + bellBtn: React.ReactNode, + lockedIcon: React.ReactNode, + shareBtn: React.ReactNode; - const info = []; + const info: React.ReactNode[] = []; if (me !== account.id && relationship?.followed_by) { info.push( @@ -763,43 +716,17 @@ export const AccountHeader: React.FC<{ ); } - if (me !== account.id) { - if (signedIn && !relationship) { - // Wait until the relationship is loaded - actionBtn = ( - - ); - } else if (!relationship?.blocking) { - actionBtn = ( -