fix: Update hashtags when (un)following a hashtag (#35101)

This commit is contained in:
diondiondion 2025-06-23 13:44:59 +02:00 committed by GitHub
parent d28a4428b5
commit b9b1500fc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 107 additions and 65 deletions

View File

@ -1,12 +1,30 @@
import { createAction } from '@reduxjs/toolkit';
import { import {
apiGetTag, apiGetTag,
apiFollowTag, apiFollowTag,
apiUnfollowTag, apiUnfollowTag,
apiFeatureTag, apiFeatureTag,
apiUnfeatureTag, apiUnfeatureTag,
apiGetFollowedTags,
} from 'mastodon/api/tags'; } from 'mastodon/api/tags';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; 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( export const fetchHashtag = createDataLoadingThunk(
'tags/fetch', 'tags/fetch',
({ tagId }: { tagId: string }) => apiGetTag(tagId), ({ tagId }: { tagId: string }) => apiGetTag(tagId),
@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
export const followHashtag = createDataLoadingThunk( export const followHashtag = createDataLoadingThunk(
'tags/follow', 'tags/follow',
({ tagId }: { tagId: string }) => apiFollowTag(tagId), ({ tagId }: { tagId: string }) => apiFollowTag(tagId),
(_, { dispatch }) => {
void dispatch(markFollowedHashtagsStale());
},
); );
export const unfollowHashtag = createDataLoadingThunk( export const unfollowHashtag = createDataLoadingThunk(

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
@ -7,8 +7,10 @@ import { Helmet } from 'react-helmet';
import { isFulfilled } from '@reduxjs/toolkit'; import { isFulfilled } from '@reduxjs/toolkit';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { unfollowHashtag } from 'mastodon/actions/tags_typed'; import {
import { apiGetFollowedTags } from 'mastodon/api/tags'; fetchFollowedHashtags,
unfollowHashtag,
} from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column'; 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 { ColumnHeader } from 'mastodon/components/column_header';
import { Hashtag } from 'mastodon/components/hashtag'; import { Hashtag } from 'mastodon/components/hashtag';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
@ -59,55 +61,32 @@ const FollowedTag: React.FC<{
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]); const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false); const { tags, loading, next, stale } = useAppSelector(
const [next, setNext] = useState<string | undefined>(); (state) => state.followedTags,
);
const hasMore = !!next; const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null);
useEffect(() => { useEffect(() => {
setLoading(true); if (stale) {
void dispatch(fetchFollowedHashtags());
void apiGetFollowedTags() }
.then(({ tags, links }) => { }, [dispatch, stale]);
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(() => { const handleLoadMore = useCallback(() => {
setLoading(true); if (next) {
void dispatch(fetchFollowedHashtags({ next }));
void apiGetFollowedTags(next) }
.then(({ tags, links }) => { }, [dispatch, next]);
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( const handleUnfollow = useCallback(
(tagId: string) => { (tagId: string) => {
setTags((tags) => tags.filter((tag) => tag.name !== tagId)); void dispatch(unfollowHashtag({ tagId }));
}, },
[setTags], [dispatch],
); );
const columnRef = useRef<ColumnRef>(null);
const handleHeaderClick = useCallback(() => { const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop(); columnRef.current?.scrollTop();
}, []); }, []);

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react';
import { apiGetFollowedTags } from 'mastodon/api/tags'; import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
import { ColumnLink } from 'mastodon/features/ui/components/column_link'; import { ColumnLink } from 'mastodon/features/ui/components/column_link';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollapsiblePanel } from './collapsible_panel'; import { CollapsiblePanel } from './collapsible_panel';
@ -24,25 +24,20 @@ const messages = defineMessages({
}, },
}); });
const TAG_LIMIT = 4;
export const FollowedTagsPanel: React.FC = () => { export const FollowedTagsPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [tags, setTags] = useState<ApiHashtagJSON[]>([]); const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false); const { tags, stale, loading } = useAppSelector(
(state) => state.followedTags,
);
useEffect(() => { useEffect(() => {
setLoading(true); if (stale) {
void dispatch(fetchFollowedHashtags());
void apiGetFollowedTags(undefined, 4) }
.then(({ tags }) => { }, [dispatch, stale]);
setTags(tags);
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setLoading, setTags]);
return ( return (
<CollapsiblePanel <CollapsiblePanel
@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
expandTitle={intl.formatMessage(messages.expand)} expandTitle={intl.formatMessage(messages.expand)}
loading={loading} loading={loading}
> >
{tags.map((tag) => ( {tags.slice(0, TAG_LIMIT).map((tag) => (
<ColumnLink <ColumnLink
transparent
icon='hashtag' icon='hashtag'
key={tag.name} key={tag.name}
iconComponent={TagIcon} iconComponent={TagIcon}
text={`#${tag.name}`} text={`#${tag.name}`}
to={`/tags/${tag.name}`} to={`/tags/${tag.name}`}
transparent
/> />
))} ))}
</CollapsiblePanel> </CollapsiblePanel>

View File

@ -16,7 +16,6 @@ export const ColumnLink: React.FC<{
method?: string; method?: string;
badge?: React.ReactNode; badge?: React.ReactNode;
transparent?: boolean; transparent?: boolean;
optional?: boolean;
className?: string; className?: string;
id?: string; id?: string;
}> = ({ }> = ({
@ -30,13 +29,11 @@ export const ColumnLink: React.FC<{
method, method,
badge, badge,
transparent, transparent,
optional,
...other ...other
}) => { }) => {
const match = useRouteMatch(to ?? ''); const match = useRouteMatch(to ?? '');
const className = classNames('column-link', { const className = classNames('column-link', {
'column-link--transparent': transparent, 'column-link--transparent': transparent,
'column-link--optional': optional,
}); });
const badgeElement = const badgeElement =
typeof badge !== 'undefined' ? ( typeof badge !== 'undefined' ? (

View File

@ -36,6 +36,7 @@ import settings from './settings';
import status_lists from './status_lists'; import status_lists from './status_lists';
import statuses from './statuses'; import statuses from './statuses';
import { suggestionsReducer } from './suggestions'; import { suggestionsReducer } from './suggestions';
import { followedTagsReducer } from './tags';
import timelines from './timelines'; import timelines from './timelines';
import trends from './trends'; import trends from './trends';
import user_lists from './user_lists'; import user_lists from './user_lists';
@ -67,6 +68,7 @@ const reducers = {
height_cache, height_cache,
custom_emojis, custom_emojis,
lists: listsReducer, lists: listsReducer,
followedTags: followedTagsReducer,
filters, filters,
conversations, conversations,
suggestions: suggestionsReducer, suggestions: suggestionsReducer,

View File

@ -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;
});
});