diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts index a3e5cfd12..1f686f0c4 100644 --- a/app/javascript/mastodon/actions/tags_typed.ts +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -1,12 +1,30 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetTag, apiFollowTag, apiUnfollowTag, apiFeatureTag, apiUnfeatureTag, + apiGetFollowedTags, } from 'mastodon/api/tags'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +export const fetchFollowedHashtags = createDataLoadingThunk( + 'tags/fetch-followed', + async ({ next }: { next?: string } = {}) => { + const response = await apiGetFollowedTags(next); + return { + ...response, + replace: !next, + }; + }, +); + +export const markFollowedHashtagsStale = createAction( + 'tags/mark-followed-stale', +); + export const fetchHashtag = createDataLoadingThunk( 'tags/fetch', ({ tagId }: { tagId: string }) => apiGetTag(tagId), @@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk( export const followHashtag = createDataLoadingThunk( 'tags/follow', ({ tagId }: { tagId: string }) => apiFollowTag(tagId), + (_, { dispatch }) => { + void dispatch(markFollowedHashtagsStale()); + }, ); export const unfollowHashtag = createDataLoadingThunk( diff --git a/app/javascript/mastodon/features/followed_tags/index.tsx b/app/javascript/mastodon/features/followed_tags/index.tsx index 21d63a6fe..4c5c0581f 100644 --- a/app/javascript/mastodon/features/followed_tags/index.tsx +++ b/app/javascript/mastodon/features/followed_tags/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; @@ -7,8 +7,10 @@ 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 { + fetchFollowedHashtags, + unfollowHashtag, +} from 'mastodon/actions/tags_typed'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import { Button } from 'mastodon/components/button'; import { Column } from 'mastodon/components/column'; @@ -16,7 +18,7 @@ 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'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, @@ -59,55 +61,32 @@ const FollowedTag: React.FC<{ const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const intl = useIntl(); - const [tags, setTags] = useState([]); - const [loading, setLoading] = useState(false); - const [next, setNext] = useState(); + const dispatch = useAppDispatch(); + const { tags, loading, next, stale } = useAppSelector( + (state) => state.followedTags, + ); 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]); + if (stale) { + void dispatch(fetchFollowedHashtags()); + } + }, [dispatch, stale]); 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]); + if (next) { + void dispatch(fetchFollowedHashtags({ next })); + } + }, [dispatch, next]); const handleUnfollow = useCallback( (tagId: string) => { - setTags((tags) => tags.filter((tag) => tag.name !== tagId)); + void dispatch(unfollowHashtag({ tagId })); }, - [setTags], + [dispatch], ); + const columnRef = useRef(null); const handleHeaderClick = useCallback(() => { columnRef.current?.scrollTop(); }, []); diff --git a/app/javascript/mastodon/features/navigation_panel/components/followed_tags_panel.tsx b/app/javascript/mastodon/features/navigation_panel/components/followed_tags_panel.tsx index e6d680b20..741a41967 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/followed_tags_panel.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/followed_tags_panel.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; -import { apiGetFollowedTags } from 'mastodon/api/tags'; -import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed'; import { ColumnLink } from 'mastodon/features/ui/components/column_link'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollapsiblePanel } from './collapsible_panel'; @@ -24,25 +24,20 @@ const messages = defineMessages({ }, }); +const TAG_LIMIT = 4; + export const FollowedTagsPanel: React.FC = () => { const intl = useIntl(); - const [tags, setTags] = useState([]); - const [loading, setLoading] = useState(false); + const dispatch = useAppDispatch(); + const { tags, stale, loading } = useAppSelector( + (state) => state.followedTags, + ); useEffect(() => { - setLoading(true); - - void apiGetFollowedTags(undefined, 4) - .then(({ tags }) => { - setTags(tags); - setLoading(false); - - return ''; - }) - .catch(() => { - setLoading(false); - }); - }, [setLoading, setTags]); + if (stale) { + void dispatch(fetchFollowedHashtags()); + } + }, [dispatch, stale]); return ( { expandTitle={intl.formatMessage(messages.expand)} loading={loading} > - {tags.map((tag) => ( + {tags.slice(0, TAG_LIMIT).map((tag) => ( ))} diff --git a/app/javascript/mastodon/features/ui/components/column_link.tsx b/app/javascript/mastodon/features/ui/components/column_link.tsx index d322c2e4c..1d46f44a8 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.tsx +++ b/app/javascript/mastodon/features/ui/components/column_link.tsx @@ -16,7 +16,6 @@ export const ColumnLink: React.FC<{ method?: string; badge?: React.ReactNode; transparent?: boolean; - optional?: boolean; className?: string; id?: string; }> = ({ @@ -30,13 +29,11 @@ export const ColumnLink: React.FC<{ method, badge, transparent, - optional, ...other }) => { const match = useRouteMatch(to ?? ''); const className = classNames('column-link', { 'column-link--transparent': transparent, - 'column-link--optional': optional, }); const badgeElement = typeof badge !== 'undefined' ? ( diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 9c3583c7a..794d993f7 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -36,6 +36,7 @@ import settings from './settings'; import status_lists from './status_lists'; import statuses from './statuses'; import { suggestionsReducer } from './suggestions'; +import { followedTagsReducer } from './tags'; import timelines from './timelines'; import trends from './trends'; import user_lists from './user_lists'; @@ -67,6 +68,7 @@ const reducers = { height_cache, custom_emojis, lists: listsReducer, + followedTags: followedTagsReducer, filters, conversations, suggestions: suggestionsReducer, diff --git a/app/javascript/mastodon/reducers/tags.ts b/app/javascript/mastodon/reducers/tags.ts new file mode 100644 index 000000000..fb43420fb --- /dev/null +++ b/app/javascript/mastodon/reducers/tags.ts @@ -0,0 +1,48 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { + fetchFollowedHashtags, + markFollowedHashtagsStale, + unfollowHashtag, +} from 'mastodon/actions/tags_typed'; +import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; + +export interface TagsQuery { + tags: ApiHashtagJSON[]; + loading: boolean; + stale: boolean; + next: string | undefined; +} + +const initialState: TagsQuery = { + tags: [], + loading: false, + stale: true, + next: undefined, +}; + +export const followedTagsReducer = createReducer(initialState, (builder) => { + builder + .addCase(fetchFollowedHashtags.pending, (state) => { + state.loading = true; + }) + .addCase(fetchFollowedHashtags.rejected, (state) => { + state.loading = false; + }) + .addCase(markFollowedHashtagsStale, (state) => { + state.stale = true; + }) + .addCase(unfollowHashtag.fulfilled, (state, action) => { + const tagId = action.payload.id; + state.tags = state.tags.filter((tag) => tag.id !== tagId); + }) + .addCase(fetchFollowedHashtags.fulfilled, (state, action) => { + const { tags, links, replace } = action.payload; + const next = links.refs.find((link) => link.rel === 'next'); + + state.tags = replace ? tags : [...state.tags, ...tags]; + state.next = next?.uri; + state.stale = false; + state.loading = false; + }); +});