diff --git a/app/javascript/flavours/glitch/actions/tags_typed.ts b/app/javascript/flavours/glitch/actions/tags_typed.ts
index 5d1ab026ee..a60c96dc4a 100644
--- a/app/javascript/flavours/glitch/actions/tags_typed.ts
+++ b/app/javascript/flavours/glitch/actions/tags_typed.ts
@@ -1,12 +1,30 @@
+import { createAction } from '@reduxjs/toolkit';
+
import {
apiGetTag,
apiFollowTag,
apiUnfollowTag,
apiFeatureTag,
apiUnfeatureTag,
+ apiGetFollowedTags,
} from 'flavours/glitch/api/tags';
import { createDataLoadingThunk } from 'flavours/glitch/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/flavours/glitch/features/compose/index.tsx b/app/javascript/flavours/glitch/features/compose/index.tsx
index 445719b493..77922f5a22 100644
--- a/app/javascript/flavours/glitch/features/compose/index.tsx
+++ b/app/javascript/flavours/glitch/features/compose/index.tsx
@@ -24,33 +24,28 @@ import { Icon } from 'flavours/glitch/components/icon';
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
-import { mascot } from 'flavours/glitch/initial_state';
+import { mascot, reduceMotion } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
+import { messages as navbarMessages } from '../ui/components/navigation_bar';
+
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
const messages = defineMessages({
- start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
- home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
- notifications: {
- id: 'tabs_bar.notifications',
- defaultMessage: 'Notifications',
+ live_feed_public: {
+ id: 'navigation_bar.live_feed_public',
+ defaultMessage: 'Live feed (public)',
},
- public: {
- id: 'navigation_bar.public_timeline',
- defaultMessage: 'Federated timeline',
- },
- community: {
- id: 'navigation_bar.community_timeline',
- defaultMessage: 'Local timeline',
+ live_feed_local: {
+ id: 'navigation_bar.live_feed_local',
+ defaultMessage: 'Live feed (local)',
},
settings: {
id: 'navigation_bar.app_settings',
defaultMessage: 'App settings',
},
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
- compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
@@ -127,19 +122,27 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
elephantUIPlane,
][elefriend];
+ const scrollNavbarIntoView = useCallback(() => {
+ const navbar = document.querySelector('.navigation-panel');
+ navbar?.scrollIntoView({
+ behavior: reduceMotion ? 'auto' : 'smooth',
+ });
+ }, []);
+
if (multiColumn) {
return (
);
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.tsx b/app/javascript/flavours/glitch/features/ui/components/column_link.tsx
index a69cffc115..a1091a1d3c 100644
--- a/app/javascript/flavours/glitch/features/ui/components/column_link.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/column_link.tsx
@@ -19,7 +19,6 @@ export const ColumnLink: React.FC<{
method?: string;
badge?: React.ReactNode;
transparent?: boolean;
- optional?: boolean;
className?: string;
id?: string;
}> = ({
@@ -34,13 +33,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/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
index 05121a921d..54da547787 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx
@@ -25,7 +25,7 @@ import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading';
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
import DrawerLoading from './drawer_loading';
-import { NavigationPanel } from 'flavours/glitch/features/navigation_panel';
+import { CollapsibleNavigationPanel } from 'flavours/glitch/features/navigation_panel';
const componentMap = {
'COMPOSE': Compose,
@@ -133,7 +133,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
{children}
-
+
);
}
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx b/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx
index c6206658a5..20a433df0d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_bar.tsx
@@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'flavours/glitch/initial_state';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
-const messages = defineMessages({
+export const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx
index c0378891bb..0069fb603f 100644
--- a/app/javascript/flavours/glitch/features/ui/index.jsx
+++ b/app/javascript/flavours/glitch/features/ui/index.jsx
@@ -69,7 +69,6 @@ import {
DomainBlocks,
Mutes,
PinnedStatuses,
- GettingStartedMisc,
Directory,
OnboardingProfile,
OnboardingFollows,
@@ -152,13 +151,8 @@ class SwitchingColumnsArea extends PureComponent {
};
UNSAFE_componentWillMount () {
- if (this.props.singleColumn) {
- document.body.classList.toggle('layout-single-column', true);
- document.body.classList.toggle('layout-multiple-columns', false);
- } else {
- document.body.classList.toggle('layout-single-column', false);
- document.body.classList.toggle('layout-multiple-columns', true);
- }
+ document.body.classList.toggle('layout-single-column', this.props.singleColumn);
+ document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
}
componentDidUpdate (prevProps) {
@@ -210,8 +204,8 @@ class SwitchingColumnsArea extends PureComponent {
{singleColumn ? : null}
{singleColumn && pathName.startsWith('/deck/') ? : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
- {!singleColumn && pathName === '/getting-started' ? : null}
{!singleColumn && pathName === '/home' ? : null}
+ {pathName === '/getting-started' ? : null}
@@ -271,7 +265,6 @@ class SwitchingColumnsArea extends PureComponent {
-
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
index c96941d15a..7f8f979e37 100644
--- a/app/javascript/flavours/glitch/features/ui/util/async-components.js
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -50,10 +50,6 @@ export function GettingStarted () {
return import('../../getting_started');
}
-export function GettingStartedMisc () {
- return import('../../getting_started_misc');
-}
-
export function KeyboardShortcuts () {
return import('../../keyboard_shortcuts');
}
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json
index 71ac1d26db..03ea2c9785 100644
--- a/app/javascript/flavours/glitch/locales/en.json
+++ b/app/javascript/flavours/glitch/locales/en.json
@@ -6,12 +6,8 @@
"account.view_full_profile": "View full profile",
"boost_modal.missing_description": "This toot contains some media without description",
"column.favourited_by": "Favourited by",
- "column.heading": "Misc",
"column.reblogged_by": "Boosted by",
- "column.subheading": "Miscellaneous options",
"column_header.profile": "Profile",
- "column_subheading.lists": "Lists",
- "column_subheading.navigation": "Navigation",
"community.column_settings.allow_local_only": "Show local-only toots",
"compose.attach.doodle": "Draw something",
"compose.change_federation": "Change federation settings",
@@ -45,8 +41,6 @@
"keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
"moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
"navigation_bar.app_settings": "App settings",
- "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
- "navigation_bar.misc": "Misc",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
"settings.always_show_spoilers_field": "Always enable the Content Warning field",
"settings.close": "Close",
diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts
index 145c3c4d26..24c33f70f5 100644
--- a/app/javascript/flavours/glitch/reducers/index.ts
+++ b/app/javascript/flavours/glitch/reducers/index.ts
@@ -37,6 +37,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';
@@ -69,6 +70,7 @@ const reducers = {
height_cache,
custom_emojis,
lists: listsReducer,
+ followedTags: followedTagsReducer,
filters,
conversations,
suggestions: suggestionsReducer,
diff --git a/app/javascript/flavours/glitch/reducers/tags.ts b/app/javascript/flavours/glitch/reducers/tags.ts
new file mode 100644
index 0000000000..328e195e6e
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/tags.ts
@@ -0,0 +1,48 @@
+import { createReducer } from '@reduxjs/toolkit';
+
+import {
+ fetchFollowedHashtags,
+ markFollowedHashtagsStale,
+ unfollowHashtag,
+} from 'flavours/glitch/actions/tags_typed';
+import type { ApiHashtagJSON } from 'flavours/glitch/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;
+ });
+});
diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts
index a3e5cfd125..1f686f0c43 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/compose/index.tsx b/app/javascript/mastodon/features/compose/index.tsx
index 54776c98ff..892cbb9761 100644
--- a/app/javascript/mastodon/features/compose/index.tsx
+++ b/app/javascript/mastodon/features/compose/index.tsx
@@ -15,39 +15,34 @@ import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
-import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
+import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
-import { mascot } from 'mastodon/initial_state';
+import { mascot, reduceMotion } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
+import { messages as navbarMessages } from '../ui/components/navigation_bar';
+
import { Search } from './components/search';
import ComposeFormContainer from './containers/compose_form_container';
const messages = defineMessages({
- start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
- home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
- notifications: {
- id: 'tabs_bar.notifications',
- defaultMessage: 'Notifications',
+ live_feed_public: {
+ id: 'navigation_bar.live_feed_public',
+ defaultMessage: 'Live feed (public)',
},
- public: {
- id: 'navigation_bar.public_timeline',
- defaultMessage: 'Federated timeline',
- },
- community: {
- id: 'navigation_bar.community_timeline',
- defaultMessage: 'Local timeline',
+ live_feed_local: {
+ id: 'navigation_bar.live_feed_local',
+ defaultMessage: 'Live feed (local)',
},
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
- compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
@@ -82,19 +77,27 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
[dispatch],
);
+ const scrollNavbarIntoView = useCallback(() => {
+ const navbar = document.querySelector('.navigation-panel');
+ navbar?.scrollIntoView({
+ behavior: reduceMotion ? 'auto' : 'smooth',
+ });
+ }, []);
+
if (multiColumn) {
return (
);
diff --git a/app/javascript/mastodon/features/ui/components/column_link.tsx b/app/javascript/mastodon/features/ui/components/column_link.tsx
index d322c2e4c4..1d46f44a84 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/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx
index 1b882a1c55..77b95d526a 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.jsx
+++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx
@@ -25,7 +25,7 @@ import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading';
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
import DrawerLoading from './drawer_loading';
-import { NavigationPanel } from 'mastodon/features/navigation_panel';
+import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
const componentMap = {
'COMPOSE': Compose,
@@ -133,7 +133,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
{children}
-
+
);
}
diff --git a/app/javascript/mastodon/features/ui/components/navigation_bar.tsx b/app/javascript/mastodon/features/ui/components/navigation_bar.tsx
index dbb70f9ec8..467e20a572 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_bar.tsx
+++ b/app/javascript/mastodon/features/ui/components/navigation_bar.tsx
@@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
-const messages = defineMessages({
+export const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index 4297d750c5..c9834eb0a4 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -142,13 +142,8 @@ class SwitchingColumnsArea extends PureComponent {
};
UNSAFE_componentWillMount () {
- if (this.props.singleColumn) {
- document.body.classList.toggle('layout-single-column', true);
- document.body.classList.toggle('layout-multiple-columns', false);
- } else {
- document.body.classList.toggle('layout-single-column', false);
- document.body.classList.toggle('layout-multiple-columns', true);
- }
+ document.body.classList.toggle('layout-single-column', this.props.singleColumn);
+ document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
}
componentDidUpdate (prevProps) {
@@ -200,8 +195,8 @@ class SwitchingColumnsArea extends PureComponent {
{singleColumn ? : null}
{singleColumn && pathName.startsWith('/deck/') ? : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
- {!singleColumn && pathName === '/getting-started' ? : null}
{!singleColumn && pathName === '/home' ? : null}
+ {pathName === '/getting-started' ? : null}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index ad5d66c164..0f53dbe576 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -184,7 +184,6 @@
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_search.cancel": "Cancel",
- "column_subheading.settings": "Settings",
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
"community.column_settings.remote_only": "Remote only",
@@ -555,12 +554,8 @@
"navigation_bar.automated_deletion": "Automated post deletion",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks",
- "navigation_bar.community_timeline": "Local timeline",
- "navigation_bar.compose": "Compose new post",
"navigation_bar.direct": "Private mentions",
- "navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Blocked domains",
- "navigation_bar.explore": "Explore",
"navigation_bar.favourites": "Favorites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
@@ -568,19 +563,17 @@
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.import_export": "Import and export",
"navigation_bar.lists": "Lists",
+ "navigation_bar.live_feed_local": "Live feed (local)",
+ "navigation_bar.live_feed_public": "Live feed (public)",
"navigation_bar.logout": "Logout",
"navigation_bar.moderation": "Moderation",
"navigation_bar.more": "More",
"navigation_bar.mutes": "Muted users",
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
- "navigation_bar.personal": "Personal",
- "navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
"navigation_bar.privacy_and_reach": "Privacy and reach",
- "navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.search": "Search",
"navigation_bar.search_trends": "Search / Trending",
- "navigation_bar.security": "Security",
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
"navigation_panel.collapse_lists": "Collapse list menu",
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index 9c3583c7a3..794d993f75 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 0000000000..fb43420fbf
--- /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;
+ });
+});