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 {
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(

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';
@ -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<ApiHashtagJSON[]>([]);
const [loading, setLoading] = useState(false);
const [next, setNext] = useState<string | undefined>();
const dispatch = useAppDispatch();
const { tags, loading, next, stale } = useAppSelector(
(state) => state.followedTags,
);
const hasMore = !!next;
const columnRef = useRef<ColumnRef>(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<ColumnRef>(null);
const handleHeaderClick = useCallback(() => {
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 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<ApiHashtagJSON[]>([]);
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 (
<CollapsiblePanel
@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
expandTitle={intl.formatMessage(messages.expand)}
loading={loading}
>
{tags.map((tag) => (
{tags.slice(0, TAG_LIMIT).map((tag) => (
<ColumnLink
transparent
icon='hashtag'
key={tag.name}
iconComponent={TagIcon}
text={`#${tag.name}`}
to={`/tags/${tag.name}`}
transparent
/>
))}
</CollapsiblePanel>

View File

@ -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' ? (

View File

@ -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,

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