((resolve) => {
+ resolve(simulateModifiedApiResponse(media, params));
+ });
+ }
+
+ return apiUpdateMedia(id, params);
+ },
+ (media: SimulatedMediaAttachmentJSON) => {
+ return {
+ media,
+ attached: typeof media.unattached !== 'undefined' && !media.unattached,
+ };
+ },
+ {
+ useLoadingBar: false,
+ },
+);
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
index 727f800af3..279ec1bef7 100644
--- a/app/javascript/mastodon/actions/domain_blocks.js
+++ b/app/javascript/mastodon/actions/domain_blocks.js
@@ -12,14 +12,6 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
-export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
-export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
-export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
-
-export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
-export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
-export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL';
-
export function blockDomain(domain) {
return (dispatch, getState) => {
dispatch(blockDomainRequest(domain));
@@ -79,80 +71,6 @@ export function unblockDomainFail(domain, error) {
};
}
-export function fetchDomainBlocks() {
- return (dispatch) => {
- dispatch(fetchDomainBlocksRequest());
-
- api().get('/api/v1/domain_blocks').then(response => {
- const next = getLinks(response).refs.find(link => link.rel === 'next');
- dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
- }).catch(err => {
- dispatch(fetchDomainBlocksFail(err));
- });
- };
-}
-
-export function fetchDomainBlocksRequest() {
- return {
- type: DOMAIN_BLOCKS_FETCH_REQUEST,
- };
-}
-
-export function fetchDomainBlocksSuccess(domains, next) {
- return {
- type: DOMAIN_BLOCKS_FETCH_SUCCESS,
- domains,
- next,
- };
-}
-
-export function fetchDomainBlocksFail(error) {
- return {
- type: DOMAIN_BLOCKS_FETCH_FAIL,
- error,
- };
-}
-
-export function expandDomainBlocks() {
- return (dispatch, getState) => {
- const url = getState().getIn(['domain_lists', 'blocks', 'next']);
-
- if (!url) {
- return;
- }
-
- dispatch(expandDomainBlocksRequest());
-
- api().get(url).then(response => {
- const next = getLinks(response).refs.find(link => link.rel === 'next');
- dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
- }).catch(err => {
- dispatch(expandDomainBlocksFail(err));
- });
- };
-}
-
-export function expandDomainBlocksRequest() {
- return {
- type: DOMAIN_BLOCKS_EXPAND_REQUEST,
- };
-}
-
-export function expandDomainBlocksSuccess(domains, next) {
- return {
- type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
- domains,
- next,
- };
-}
-
-export function expandDomainBlocksFail(error) {
- return {
- type: DOMAIN_BLOCKS_EXPAND_FAIL,
- error,
- };
-}
-
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
modalType: 'DOMAIN_BLOCK',
modalProps: {
diff --git a/app/javascript/mastodon/actions/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts
index 3694df1ae0..d9d395ba33 100644
--- a/app/javascript/mastodon/actions/dropdown_menu.ts
+++ b/app/javascript/mastodon/actions/dropdown_menu.ts
@@ -1,11 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
export const openDropdownMenu = createAction<{
- id: string;
+ id: number;
keyboard: boolean;
- scrollKey: string;
+ scrollKey?: string;
}>('dropdownMenu/open');
-export const closeDropdownMenu = createAction<{ id: string }>(
+export const closeDropdownMenu = createAction<{ id: number }>(
'dropdownMenu/close',
);
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index ebf58b761a..fc165b1a1f 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -1,10 +1,12 @@
+import { createPollFromServerJSON } from 'mastodon/models/poll';
+
import { importAccounts } from '../accounts_typed';
-import { normalizeStatus, normalizePoll } from './normalizer';
+import { normalizeStatus } from './normalizer';
+import { importPolls } from './polls';
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
-export const POLLS_IMPORT = 'POLLS_IMPORT';
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
function pushUnique(array, object) {
@@ -25,10 +27,6 @@ export function importFilters(filters) {
return { type: FILTERS_IMPORT, filters };
}
-export function importPolls(polls) {
- return { type: POLLS_IMPORT, polls };
-}
-
export function importFetchedAccount(account) {
return importFetchedAccounts([account]);
}
@@ -77,7 +75,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll?.id) {
- pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
+ pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
}
if (status.card) {
@@ -87,15 +85,9 @@ export function importFetchedStatuses(statuses) {
statuses.forEach(processStatus);
- dispatch(importPolls(polls));
+ dispatch(importPolls({ polls }));
dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses));
dispatch(importFilters(filters));
};
}
-
-export function importFetchedPoll(poll) {
- return (dispatch, getState) => {
- dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
- };
-}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index d9e9fef0c6..b643cf5613 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -1,15 +1,12 @@
import escapeTextContentForBrowser from 'escape-html';
+import { makeEmojiMap } from 'mastodon/models/custom_emoji';
+
import emojify from '../../features/emoji/emoji';
import { expandSpoilers, me } from '../../initial_state';
const domParser = new DOMParser();
-const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
- obj[`:${emoji.shortcode}:`] = emoji;
- return obj;
-}, {});
-
export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/
/g, '\n').replace(/<\/p>/g, '\n\n');
@@ -140,38 +137,6 @@ export function normalizeStatusTranslation(translation, status) {
return normalTranslation;
}
-export function normalizePoll(poll, normalOldPoll) {
- const normalPoll = { ...poll };
- const emojiMap = makeEmojiMap(poll.emojis);
-
- normalPoll.options = poll.options.map((option, index) => {
- const normalOption = {
- ...option,
- voted: poll.own_votes && poll.own_votes.includes(index),
- titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
- };
-
- if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
- normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
- }
-
- return normalOption;
- });
-
- return normalPoll;
-}
-
-export function normalizePollOptionTranslation(translation, poll) {
- const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
-
- const normalTranslation = {
- ...translation,
- titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
- };
-
- return normalTranslation;
-}
-
export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
diff --git a/app/javascript/mastodon/actions/importer/polls.ts b/app/javascript/mastodon/actions/importer/polls.ts
new file mode 100644
index 0000000000..5bbe7d57d6
--- /dev/null
+++ b/app/javascript/mastodon/actions/importer/polls.ts
@@ -0,0 +1,7 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { Poll } from 'mastodon/models/poll';
+
+export const importPolls = createAction<{ polls: Poll[] }>(
+ 'poll/importMultiple',
+);
diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js
index a7cc1a9329..7b6dd22041 100644
--- a/app/javascript/mastodon/actions/lists.js
+++ b/app/javascript/mastodon/actions/lists.js
@@ -2,9 +2,6 @@
import api from '../api';
-import { showAlertForError } from './alerts';
-import { importFetchedAccounts } from './importer';
-
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
@@ -13,45 +10,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
-export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
-export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
-export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
-
-export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
-export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
-export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
-
-export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
-export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
-export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
-
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
-export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
-export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
-export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
-
-export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
-export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
-export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
-
-export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
-export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
-export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
-
-export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
-export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
-export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
-
-export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
-export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
-
-export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
-export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
-export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
-
export const fetchList = id => (dispatch, getState) => {
if (getState().getIn(['lists', id])) {
return;
@@ -102,94 +64,6 @@ export const fetchListsFail = error => ({
error,
});
-export const submitListEditor = shouldReset => (dispatch, getState) => {
- const listId = getState().getIn(['listEditor', 'listId']);
- const title = getState().getIn(['listEditor', 'title']);
-
- if (listId === null) {
- dispatch(createList(title, shouldReset));
- } else {
- dispatch(updateList(listId, title, shouldReset));
- }
-};
-
-export const setupListEditor = listId => (dispatch, getState) => {
- dispatch({
- type: LIST_EDITOR_SETUP,
- list: getState().getIn(['lists', listId]),
- });
-
- dispatch(fetchListAccounts(listId));
-};
-
-export const changeListEditorTitle = value => ({
- type: LIST_EDITOR_TITLE_CHANGE,
- value,
-});
-
-export const createList = (title, shouldReset) => (dispatch) => {
- dispatch(createListRequest());
-
- api().post('/api/v1/lists', { title }).then(({ data }) => {
- dispatch(createListSuccess(data));
-
- if (shouldReset) {
- dispatch(resetListEditor());
- }
- }).catch(err => dispatch(createListFail(err)));
-};
-
-export const createListRequest = () => ({
- type: LIST_CREATE_REQUEST,
-});
-
-export const createListSuccess = list => ({
- type: LIST_CREATE_SUCCESS,
- list,
-});
-
-export const createListFail = error => ({
- type: LIST_CREATE_FAIL,
- error,
-});
-
-export const updateList = (id, title, shouldReset, isExclusive, replies_policy, notify) => (dispatch) => {
- dispatch(updateListRequest(id));
-
- api().put(`/api/v1/lists/${id}`, {
- title,
- replies_policy,
- exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive,
- notify: typeof notify === 'undefined' ? undefined : !!notify,
- }).then(({ data }) => {
- dispatch(updateListSuccess(data));
-
- if (shouldReset) {
- dispatch(resetListEditor());
- }
- }).catch(err => dispatch(updateListFail(id, err)));
-};
-
-export const updateListRequest = id => ({
- type: LIST_UPDATE_REQUEST,
- id,
-});
-
-export const updateListSuccess = list => ({
- type: LIST_UPDATE_SUCCESS,
- list,
-});
-
-export const updateListFail = (id, error) => ({
- type: LIST_UPDATE_FAIL,
- id,
- error,
-});
-
-export const resetListEditor = () => ({
- type: LIST_EDITOR_RESET,
-});
-
export const deleteList = id => (dispatch) => {
dispatch(deleteListRequest(id));
@@ -213,166 +87,3 @@ export const deleteListFail = (id, error) => ({
id,
error,
});
-
-export const fetchListAccounts = listId => (dispatch) => {
- dispatch(fetchListAccountsRequest(listId));
-
- api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
- dispatch(importFetchedAccounts(data));
- dispatch(fetchListAccountsSuccess(listId, data));
- }).catch(err => dispatch(fetchListAccountsFail(listId, err)));
-};
-
-export const fetchListAccountsRequest = id => ({
- type: LIST_ACCOUNTS_FETCH_REQUEST,
- id,
-});
-
-export const fetchListAccountsSuccess = (id, accounts, next) => ({
- type: LIST_ACCOUNTS_FETCH_SUCCESS,
- id,
- accounts,
- next,
-});
-
-export const fetchListAccountsFail = (id, error) => ({
- type: LIST_ACCOUNTS_FETCH_FAIL,
- id,
- error,
-});
-
-export const fetchListSuggestions = q => (dispatch) => {
- const params = {
- q,
- resolve: false,
- following: true,
- };
-
- api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
- dispatch(importFetchedAccounts(data));
- dispatch(fetchListSuggestionsReady(q, data));
- }).catch(error => dispatch(showAlertForError(error)));
-};
-
-export const fetchListSuggestionsReady = (query, accounts) => ({
- type: LIST_EDITOR_SUGGESTIONS_READY,
- query,
- accounts,
-});
-
-export const clearListSuggestions = () => ({
- type: LIST_EDITOR_SUGGESTIONS_CLEAR,
-});
-
-export const changeListSuggestions = value => ({
- type: LIST_EDITOR_SUGGESTIONS_CHANGE,
- value,
-});
-
-export const addToListEditor = accountId => (dispatch, getState) => {
- dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
-};
-
-export const addToList = (listId, accountId) => (dispatch) => {
- dispatch(addToListRequest(listId, accountId));
-
- api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
- .then(() => dispatch(addToListSuccess(listId, accountId)))
- .catch(err => dispatch(addToListFail(listId, accountId, err)));
-};
-
-export const addToListRequest = (listId, accountId) => ({
- type: LIST_EDITOR_ADD_REQUEST,
- listId,
- accountId,
-});
-
-export const addToListSuccess = (listId, accountId) => ({
- type: LIST_EDITOR_ADD_SUCCESS,
- listId,
- accountId,
-});
-
-export const addToListFail = (listId, accountId, error) => ({
- type: LIST_EDITOR_ADD_FAIL,
- listId,
- accountId,
- error,
-});
-
-export const removeFromListEditor = accountId => (dispatch, getState) => {
- dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
-};
-
-export const removeFromList = (listId, accountId) => (dispatch) => {
- dispatch(removeFromListRequest(listId, accountId));
-
- api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
- .then(() => dispatch(removeFromListSuccess(listId, accountId)))
- .catch(err => dispatch(removeFromListFail(listId, accountId, err)));
-};
-
-export const removeFromListRequest = (listId, accountId) => ({
- type: LIST_EDITOR_REMOVE_REQUEST,
- listId,
- accountId,
-});
-
-export const removeFromListSuccess = (listId, accountId) => ({
- type: LIST_EDITOR_REMOVE_SUCCESS,
- listId,
- accountId,
-});
-
-export const removeFromListFail = (listId, accountId, error) => ({
- type: LIST_EDITOR_REMOVE_FAIL,
- listId,
- accountId,
- error,
-});
-
-export const resetListAdder = () => ({
- type: LIST_ADDER_RESET,
-});
-
-export const setupListAdder = accountId => (dispatch, getState) => {
- dispatch({
- type: LIST_ADDER_SETUP,
- account: getState().getIn(['accounts', accountId]),
- });
- dispatch(fetchLists());
- dispatch(fetchAccountLists(accountId));
-};
-
-export const fetchAccountLists = accountId => (dispatch) => {
- dispatch(fetchAccountListsRequest(accountId));
-
- api().get(`/api/v1/accounts/${accountId}/lists`)
- .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
- .catch(err => dispatch(fetchAccountListsFail(accountId, err)));
-};
-
-export const fetchAccountListsRequest = id => ({
- type:LIST_ADDER_LISTS_FETCH_REQUEST,
- id,
-});
-
-export const fetchAccountListsSuccess = (id, lists) => ({
- type: LIST_ADDER_LISTS_FETCH_SUCCESS,
- id,
- lists,
-});
-
-export const fetchAccountListsFail = (id, err) => ({
- type: LIST_ADDER_LISTS_FETCH_FAIL,
- id,
- err,
-});
-
-export const addToListAdder = listId => (dispatch, getState) => {
- dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
-};
-
-export const removeFromListAdder = listId => (dispatch, getState) => {
- dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
-};
diff --git a/app/javascript/mastodon/actions/lists_typed.ts b/app/javascript/mastodon/actions/lists_typed.ts
new file mode 100644
index 0000000000..eca051f52c
--- /dev/null
+++ b/app/javascript/mastodon/actions/lists_typed.ts
@@ -0,0 +1,17 @@
+import { apiCreate, apiUpdate } from 'mastodon/api/lists';
+import type { List } from 'mastodon/models/list';
+import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+
+export const createList = createDataLoadingThunk(
+ 'list/create',
+ (list: Partial) => apiCreate(list),
+);
+
+// Kmyblue tracking marker: copied antenna, circle, bookmark_category
+
+export const updateList = createDataLoadingThunk(
+ 'list/update',
+ (list: Partial) => apiUpdate(list),
+);
+
+// Kmyblue tracking marker: copied antenna, circle, bookmark_category
diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts
index ab03e46765..49af176a11 100644
--- a/app/javascript/mastodon/actions/modal.ts
+++ b/app/javascript/mastodon/actions/modal.ts
@@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload {
modalType: ModalType;
modalProps: ModalProps;
+ previousModalProps?: ModalProps;
}
export const openModal = createAction('MODAL_OPEN');
diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts
index 4f24c1c106..c7b192accc 100644
--- a/app/javascript/mastodon/actions/notification_groups.ts
+++ b/app/javascript/mastodon/actions/notification_groups.ts
@@ -8,13 +8,15 @@ import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type {
ApiNotificationGroupJSON,
ApiNotificationJSON,
+ NotificationType,
} from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
-import { usePendingItems } from 'mastodon/initial_state';
+import { enableEmojiReaction, usePendingItems } from 'mastodon/initial_state';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
+ selectSettingsNotificationsGroupFollows,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsShows,
} from 'mastodon/selectors/settings';
@@ -35,9 +37,15 @@ function excludeAllTypesExcept(filter: string) {
function getExcludedTypes(state: RootState) {
const activeFilter = selectSettingsNotificationsQuickFilterActive(state);
- return activeFilter === 'all'
- ? selectSettingsNotificationsExcludedTypes(state)
- : excludeAllTypesExcept(activeFilter);
+ const types =
+ activeFilter === 'all'
+ ? selectSettingsNotificationsExcludedTypes(state)
+ : excludeAllTypesExcept(activeFilter);
+ if (!enableEmojiReaction && !types.includes('emoji_reaction')) {
+ types.push('emoji_reaction');
+ }
+
+ return types;
}
function dispatchAssociatedRecords(
@@ -68,21 +76,19 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses));
}
-const supportedGroupedNotificationTypes = [
- 'favourite',
- 'reblog',
- 'emoji_reaction',
-];
+function selectNotificationGroupedTypes(state: RootState) {
+ const types: NotificationType[] = ['favourite', 'reblog', 'emoji_reaction'];
-export function shouldGroupNotificationType(type: string) {
- return supportedGroupedNotificationTypes.includes(type);
+ if (selectSettingsNotificationsGroupFollows(state)) types.push('follow');
+
+ return types;
}
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) =>
apiFetchNotificationGroups({
- grouped_types: supportedGroupedNotificationTypes,
+ grouped_types: selectNotificationGroupedTypes(getState()),
exclude_types: getExcludedTypes(getState()),
}),
({ notifications, accounts, statuses }, { dispatch }) => {
@@ -106,7 +112,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({
- grouped_types: supportedGroupedNotificationTypes,
+ grouped_types: selectNotificationGroupedTypes(getState()),
max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()),
}),
@@ -123,7 +129,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotificationGroups({
- grouped_types: supportedGroupedNotificationTypes,
+ grouped_types: selectNotificationGroupedTypes(getState()),
max_id: undefined,
exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
@@ -141,6 +147,9 @@ export const pollRecentNotifications = createDataLoadingThunk(
return { notifications };
},
+ {
+ useLoadingBar: false,
+ },
);
export const processNewNotificationForGroups = createAppAsyncThunk(
@@ -152,7 +161,7 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn =
activeFilter === 'all'
- ? notificationShows[notification.type]
+ ? notificationShows[notification.type] !== false
: activeFilter === notification.type;
if (!showInColumn) return;
@@ -172,7 +181,10 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
dispatchAssociatedRecords(dispatch, [notification]);
- return notification;
+ return {
+ notification,
+ groupedTypes: selectNotificationGroupedTypes(state),
+ };
},
);
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index f8b2aa13a4..87b842e51f 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -1,57 +1,25 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl';
-import { List as ImmutableList } from 'immutable';
-
-import { compareId } from 'mastodon/compare_id';
-import { enableEmojiReaction, usePendingItems as preferPendingItems } from 'mastodon/initial_state';
-
-import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests } from './accounts';
import {
importFetchedAccount,
- importFetchedAccounts,
- importFetchedStatus,
- importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
-import { saveSettings } from './settings';
import { STATUS_EMOJI_REACTION_UPDATE } from './statuses';
export * from "./notifications_typed";
-export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
-
-export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
-export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
-export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
-
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
-export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
-export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
-
-export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
-export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
-
-export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
-
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
-export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
-export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
-export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
-
-export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
-export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
-export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
-
const messages = defineMessages({
// mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@@ -69,10 +37,6 @@ const messages = defineMessages({
message_update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
});
-export const loadPending = () => ({
- type: NOTIFICATIONS_LOAD_PENDING,
-});
-
export function updateEmojiReactions(emoji_reaction) {
return (dispatch) =>
dispatch({
@@ -83,8 +47,6 @@ export function updateEmojiReactions(emoji_reaction) {
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
- const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
- const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
@@ -93,7 +55,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
- if (filters.some(result => result.filter.filter_action_ex === 'hide')) {
+ if (filters.some(result => result.filter.filter_action === 'hide')) {
return;
}
@@ -106,25 +68,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(submitMarkers());
- if (showInColumn) {
- dispatch(importFetchedAccount(notification.account));
-
- if (notification.status) {
- dispatch(importFetchedStatus(notification.status));
- }
-
- if (notification.report) {
- dispatch(importFetchedAccount(notification.report.target_account));
- }
-
-
- dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
- } else if (playSound && !filtered) {
- dispatch({
- type: NOTIFICATIONS_UPDATE_NOOP,
- meta: { sound: 'boop' },
- });
- }
+ // `notificationsUpdate` is still used in `user_lists` and `relationships` reducers
+ dispatch(importFetchedAccount(notification.account));
+ dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered}));
// Desktop notifications
if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
@@ -145,149 +91,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
};
}
-const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
-
-const excludeTypesFromFilter = filter => {
- const allTypes = ImmutableList([
- 'follow',
- 'follow_request',
- 'favourite',
- 'emoji_reaction',
- 'reblog',
- 'status_reference',
- 'mention',
- 'poll',
- 'status',
- 'list_status',
- 'update',
- 'admin.sign_up',
- 'admin.report',
- ]);
-
- return allTypes.filterNot(item => item === filter).toJS();
-};
-
const noOp = () => {};
-let expandNotificationsController = new AbortController();
-
-export function expandNotifications({ maxId = undefined, forceLoad = false }) {
- return async (dispatch, getState) => {
- const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
- const notifications = getState().get('notifications');
- const isLoadingMore = !!maxId;
-
- if (notifications.get('isLoading')) {
- if (forceLoad) {
- expandNotificationsController.abort();
- expandNotificationsController = new AbortController();
- } else {
- return;
- }
- }
-
- let exclude_types = activeFilter === 'all'
- ? excludeTypesFromSettings(getState())
- : excludeTypesFromFilter(activeFilter);
- if (!enableEmojiReaction && !exclude_types.includes('emoji_reaction')) {
- exclude_types.push('emoji_reaction');
- }
-
- const params = {
- max_id: maxId,
- exclude_types,
- };
-
- if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
- const a = notifications.getIn(['pendingItems', 0, 'id']);
- const b = notifications.getIn(['items', 0, 'id']);
-
- if (a && b && compareId(a, b) > 0) {
- params.since_id = a;
- } else {
- params.since_id = b || a;
- }
- }
-
- const isLoadingRecent = !!params.since_id;
-
- dispatch(expandNotificationsRequest(isLoadingMore));
-
- try {
- const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal });
- const next = getLinks(response).refs.find(link => link.rel === 'next');
-
- dispatch(importFetchedAccounts(response.data.map(item => item.account)));
- dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
- dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
-
- dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
- dispatch(submitMarkers());
- } catch(error) {
- dispatch(expandNotificationsFail(error, isLoadingMore));
- }
- };
-}
-
-export function expandNotificationsRequest(isLoadingMore) {
- return {
- type: NOTIFICATIONS_EXPAND_REQUEST,
- skipLoading: !isLoadingMore,
- };
-}
-
-export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
- return {
- type: NOTIFICATIONS_EXPAND_SUCCESS,
- notifications,
- next,
- isLoadingRecent: isLoadingRecent,
- usePendingItems,
- skipLoading: !isLoadingMore,
- };
-}
-
-export function expandNotificationsFail(error, isLoadingMore) {
- return {
- type: NOTIFICATIONS_EXPAND_FAIL,
- error,
- skipLoading: !isLoadingMore,
- skipAlert: !isLoadingMore || error.name === 'AbortError',
- };
-}
-
-export function scrollTopNotifications(top) {
- return {
- type: NOTIFICATIONS_SCROLL_TOP,
- top,
- };
-}
-
-export function setFilter (filterType) {
- return dispatch => {
- dispatch({
- type: NOTIFICATIONS_FILTER_SET,
- path: ['notifications', 'quickFilter', 'active'],
- value: filterType,
- });
- dispatch(expandNotifications({ forceLoad: true }));
- dispatch(saveSettings());
- };
-}
-
-export const mountNotifications = () => ({
- type: NOTIFICATIONS_MOUNT,
-});
-
-export const unmountNotifications = () => ({
- type: NOTIFICATIONS_UNMOUNT,
-});
-
-
-export const markNotificationsAsRead = () => ({
- type: NOTIFICATIONS_MARK_AS_READ,
-});
-
// Browser support
export function setupBrowserNotifications() {
return dispatch => {
diff --git a/app/javascript/mastodon/actions/notifications_migration.tsx b/app/javascript/mastodon/actions/notifications_migration.tsx
deleted file mode 100644
index cd9f5ca3d6..0000000000
--- a/app/javascript/mastodon/actions/notifications_migration.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { createAppAsyncThunk } from 'mastodon/store';
-
-import { fetchNotifications } from './notification_groups';
-
-export const initializeNotifications = createAppAsyncThunk(
- 'notifications/initialize',
- (_, { dispatch }) => {
- void dispatch(fetchNotifications());
- },
-);
diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts
index 88d942d45e..3eb1230666 100644
--- a/app/javascript/mastodon/actions/notifications_typed.ts
+++ b/app/javascript/mastodon/actions/notifications_typed.ts
@@ -9,7 +9,6 @@ export const notificationsUpdate = createAction(
...args
}: {
notification: ApiNotificationJSON;
- usePendingItems: boolean;
playSound: boolean;
}) => ({
payload: args,
diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js
deleted file mode 100644
index aa49341444..0000000000
--- a/app/javascript/mastodon/actions/polls.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import api from '../api';
-
-import { importFetchedPoll } from './importer';
-
-export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
-export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
-export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
-
-export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
-export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
-export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
-
-export const vote = (pollId, choices) => (dispatch) => {
- dispatch(voteRequest());
-
- api().post(`/api/v1/polls/${pollId}/votes`, { choices })
- .then(({ data }) => {
- dispatch(importFetchedPoll(data));
- dispatch(voteSuccess(data));
- })
- .catch(err => dispatch(voteFail(err)));
-};
-
-export const fetchPoll = pollId => (dispatch) => {
- dispatch(fetchPollRequest());
-
- api().get(`/api/v1/polls/${pollId}`)
- .then(({ data }) => {
- dispatch(importFetchedPoll(data));
- dispatch(fetchPollSuccess(data));
- })
- .catch(err => dispatch(fetchPollFail(err)));
-};
-
-export const voteRequest = () => ({
- type: POLL_VOTE_REQUEST,
-});
-
-export const voteSuccess = poll => ({
- type: POLL_VOTE_SUCCESS,
- poll,
-});
-
-export const voteFail = error => ({
- type: POLL_VOTE_FAIL,
- error,
-});
-
-export const fetchPollRequest = () => ({
- type: POLL_FETCH_REQUEST,
-});
-
-export const fetchPollSuccess = poll => ({
- type: POLL_FETCH_SUCCESS,
- poll,
-});
-
-export const fetchPollFail = error => ({
- type: POLL_FETCH_FAIL,
- error,
-});
diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts
new file mode 100644
index 0000000000..65a96e8f62
--- /dev/null
+++ b/app/javascript/mastodon/actions/polls.ts
@@ -0,0 +1,40 @@
+import { apiGetPoll, apiPollVote } from 'mastodon/api/polls';
+import type { ApiPollJSON } from 'mastodon/api_types/polls';
+import { createPollFromServerJSON } from 'mastodon/models/poll';
+import {
+ createAppAsyncThunk,
+ createDataLoadingThunk,
+} from 'mastodon/store/typed_functions';
+
+import { importPolls } from './importer/polls';
+
+export const importFetchedPoll = createAppAsyncThunk(
+ 'poll/importFetched',
+ (args: { poll: ApiPollJSON }, { dispatch, getState }) => {
+ const { poll } = args;
+
+ dispatch(
+ importPolls({
+ polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
+ }),
+ );
+ },
+);
+
+export const vote = createDataLoadingThunk(
+ 'poll/vote',
+ ({ pollId, choices }: { pollId: string; choices: string[] }) =>
+ apiPollVote(pollId, choices),
+ async (poll, { dispatch, discardLoadData }) => {
+ await dispatch(importFetchedPoll({ poll }));
+ return discardLoadData;
+ },
+);
+
+export const fetchPoll = createDataLoadingThunk(
+ 'poll/fetch',
+ ({ pollId }: { pollId: string }) => apiGetPoll(pollId),
+ async (poll, { dispatch }) => {
+ await dispatch(importFetchedPoll({ poll }));
+ },
+);
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
index b3d3850e31..647a6bd9fb 100644
--- a/app/javascript/mastodon/actions/push_notifications/registerer.js
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -33,7 +33,7 @@ const unsubscribe = ({ registration, subscription }) =>
subscription ? subscription.unsubscribe().then(() => registration) : registration;
const sendSubscriptionToBackend = (subscription) => {
- const params = { subscription };
+ const params = { subscription: { ...subscription.toJSON(), standard: true } };
if (me) {
const data = pushNotificationsSetting.get(me);
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
deleted file mode 100644
index bde17ae0db..0000000000
--- a/app/javascript/mastodon/actions/search.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import { fromJS } from 'immutable';
-
-import { searchHistory } from 'mastodon/settings';
-
-import api from '../api';
-
-import { fetchRelationships } from './accounts';
-import { importFetchedAccounts, importFetchedStatuses } from './importer';
-
-export const SEARCH_CHANGE = 'SEARCH_CHANGE';
-export const SEARCH_CLEAR = 'SEARCH_CLEAR';
-export const SEARCH_SHOW = 'SEARCH_SHOW';
-
-export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
-export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
-export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
-
-export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
-export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
-export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
-
-export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
-
-export function changeSearch(value) {
- return {
- type: SEARCH_CHANGE,
- value,
- };
-}
-
-export function clearSearch() {
- return {
- type: SEARCH_CLEAR,
- };
-}
-
-export function submitSearch(type) {
- return (dispatch, getState) => {
- const value = getState().getIn(['search', 'value']);
- const signedIn = !!getState().getIn(['meta', 'me']);
-
- if (value.length === 0) {
- dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
- return;
- }
-
- dispatch(fetchSearchRequest(type));
-
- api().get('/api/v2/search', {
- params: {
- q: value,
- resolve: signedIn,
- limit: 11,
- type,
- },
- }).then(response => {
- if (response.data.accounts) {
- dispatch(importFetchedAccounts(response.data.accounts));
- }
-
- if (response.data.statuses) {
- dispatch(importFetchedStatuses(response.data.statuses));
- }
-
- dispatch(fetchSearchSuccess(response.data, value, type));
- dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
- }).catch(error => {
- dispatch(fetchSearchFail(error));
- });
- };
-}
-
-export function fetchSearchRequest(searchType) {
- return {
- type: SEARCH_FETCH_REQUEST,
- searchType,
- };
-}
-
-export function fetchSearchSuccess(results, searchTerm, searchType) {
- return {
- type: SEARCH_FETCH_SUCCESS,
- results,
- searchType,
- searchTerm,
- };
-}
-
-export function fetchSearchFail(error) {
- return {
- type: SEARCH_FETCH_FAIL,
- error,
- };
-}
-
-export const expandSearch = type => (dispatch, getState) => {
- const value = getState().getIn(['search', 'value']);
- const offset = getState().getIn(['search', 'results', type]).size - 1;
-
- dispatch(expandSearchRequest(type));
-
- api().get('/api/v2/search', {
- params: {
- q: value,
- type,
- offset,
- limit: 11,
- },
- }).then(({ data }) => {
- if (data.accounts) {
- dispatch(importFetchedAccounts(data.accounts));
- }
-
- if (data.statuses) {
- dispatch(importFetchedStatuses(data.statuses));
- }
-
- dispatch(expandSearchSuccess(data, value, type));
- dispatch(fetchRelationships(data.accounts.map(item => item.id)));
- }).catch(error => {
- dispatch(expandSearchFail(error));
- });
-};
-
-export const expandSearchRequest = (searchType) => ({
- type: SEARCH_EXPAND_REQUEST,
- searchType,
-});
-
-export const expandSearchSuccess = (results, searchTerm, searchType) => ({
- type: SEARCH_EXPAND_SUCCESS,
- results,
- searchTerm,
- searchType,
-});
-
-export const expandSearchFail = error => ({
- type: SEARCH_EXPAND_FAIL,
- error,
-});
-
-export const showSearch = () => ({
- type: SEARCH_SHOW,
-});
-
-export const openURL = (value, history, onFailure) => (dispatch, getState) => {
- const signedIn = !!getState().getIn(['meta', 'me']);
-
- if (!signedIn) {
- if (onFailure) {
- onFailure();
- }
-
- return;
- }
-
- dispatch(fetchSearchRequest());
-
- api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
- if (response.data.accounts?.length > 0) {
- dispatch(importFetchedAccounts(response.data.accounts));
- history.push(`/@${response.data.accounts[0].acct}`);
- } else if (response.data.statuses?.length > 0) {
- dispatch(importFetchedStatuses(response.data.statuses));
- history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
- } else if (onFailure) {
- onFailure();
- }
-
- dispatch(fetchSearchSuccess(response.data, value));
- }).catch(err => {
- dispatch(fetchSearchFail(err));
-
- if (onFailure) {
- onFailure();
- }
- });
-};
-
-export const clickSearchResult = (q, type) => (dispatch, getState) => {
- const previous = getState().getIn(['search', 'recent']);
-
- if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
- return;
- }
-
- const me = getState().getIn(['meta', 'me']);
- const current = previous.add(fromJS({ type, q })).takeLast(4);
-
- searchHistory.set(me, current.toJS());
- dispatch(updateSearchHistory(current));
-};
-
-export const forgetSearchResult = q => (dispatch, getState) => {
- const previous = getState().getIn(['search', 'recent']);
- const me = getState().getIn(['meta', 'me']);
- const current = previous.filterNot(result => result.get('q') === q);
-
- searchHistory.set(me, current.toJS());
- dispatch(updateSearchHistory(current));
-};
-
-export const updateSearchHistory = recent => ({
- type: SEARCH_HISTORY_UPDATE,
- recent,
-});
-
-export const hydrateSearch = () => (dispatch, getState) => {
- const me = getState().getIn(['meta', 'me']);
- const history = searchHistory.get(me);
-
- if (history !== null) {
- dispatch(updateSearchHistory(history));
- }
-};
diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts
new file mode 100644
index 0000000000..13a4ee4432
--- /dev/null
+++ b/app/javascript/mastodon/actions/search.ts
@@ -0,0 +1,148 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import { apiGetSearch } from 'mastodon/api/search';
+import type { ApiSearchType } from 'mastodon/api_types/search';
+import type {
+ RecentSearch,
+ SearchType as RecentSearchType,
+} from 'mastodon/models/search';
+import { searchHistory } from 'mastodon/settings';
+import {
+ createDataLoadingThunk,
+ createAppAsyncThunk,
+} from 'mastodon/store/typed_functions';
+
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts, importFetchedStatuses } from './importer';
+
+export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
+
+export const submitSearch = createDataLoadingThunk(
+ 'search/submit',
+ async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
+ const signedIn = !!getState().meta.get('me');
+
+ return apiGetSearch({
+ q,
+ type,
+ resolve: signedIn,
+ limit: 11,
+ });
+ },
+ (data, { dispatch }) => {
+ if (data.accounts.length > 0) {
+ dispatch(importFetchedAccounts(data.accounts));
+ dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
+ }
+
+ if (data.statuses.length > 0) {
+ dispatch(importFetchedStatuses(data.statuses));
+ }
+
+ return data;
+ },
+ {
+ useLoadingBar: false,
+ },
+);
+
+export const expandSearch = createDataLoadingThunk(
+ 'search/expand',
+ async ({ type }: { type: ApiSearchType }, { getState }) => {
+ const q = getState().search.q;
+ const results = getState().search.results;
+ const offset = results?.[type].length;
+
+ return apiGetSearch({
+ q,
+ type,
+ limit: 10,
+ offset,
+ });
+ },
+ (data, { dispatch }) => {
+ if (data.accounts.length > 0) {
+ dispatch(importFetchedAccounts(data.accounts));
+ dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
+ }
+
+ if (data.statuses.length > 0) {
+ dispatch(importFetchedStatuses(data.statuses));
+ }
+
+ return data;
+ },
+ {
+ useLoadingBar: true,
+ },
+);
+
+export const openURL = createDataLoadingThunk(
+ 'search/openURL',
+ ({ url }: { url: string }) =>
+ apiGetSearch({
+ q: url,
+ resolve: true,
+ limit: 1,
+ }),
+ (data, { dispatch }) => {
+ if (data.accounts.length > 0) {
+ dispatch(importFetchedAccounts(data.accounts));
+ } else if (data.statuses.length > 0) {
+ dispatch(importFetchedStatuses(data.statuses));
+ }
+
+ return data;
+ },
+ {
+ useLoadingBar: true,
+ },
+);
+
+export const clickSearchResult = createAppAsyncThunk(
+ 'search/clickResult',
+ (
+ { q, type }: { q: string; type?: RecentSearchType },
+ { dispatch, getState },
+ ) => {
+ const previous = getState().search.recent;
+
+ if (previous.some((x) => x.q === q && x.type === type)) {
+ return;
+ }
+
+ const me = getState().meta.get('me') as string;
+ const current = [{ type, q }, ...previous].slice(0, 4);
+
+ searchHistory.set(me, current);
+ dispatch(updateSearchHistory(current));
+ },
+);
+
+export const forgetSearchResult = createAppAsyncThunk(
+ 'search/forgetResult',
+ (q: string, { dispatch, getState }) => {
+ const previous = getState().search.recent;
+ const me = getState().meta.get('me') as string;
+ const current = previous.filter((result) => result.q !== q);
+
+ searchHistory.set(me, current);
+ dispatch(updateSearchHistory(current));
+ },
+);
+
+export const updateSearchHistory = createAction(
+ 'search/updateHistory',
+);
+
+export const hydrateSearch = createAppAsyncThunk(
+ 'search/hydrate',
+ (_args, { dispatch, getState }) => {
+ const me = getState().meta.get('me') as string;
+ const history = searchHistory.get(me) as RecentSearch[] | null;
+
+ if (history !== null) {
+ dispatch(updateSearchHistory(history));
+ }
+ },
+);
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index fbd89f9d4b..7659fb5f98 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => {
api().put('/api/web/settings', { data })
.then(() => dispatch({ type: SETTING_SAVE }))
.catch(error => dispatch(showAlertForError(error)));
-}, 5000, { trailing: true });
+}, 2000, { leading: true, trailing: true });
export function saveSettings() {
return (dispatch, getState) => debouncedSave(dispatch, getState);
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 40ead34782..5064e65e7b 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -148,7 +148,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id));
- api().delete(`/api/v1/statuses/${id}`).then(response => {
+ api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
index 8ab75cdc44..e8fec13453 100644
--- a/app/javascript/mastodon/actions/store.js
+++ b/app/javascript/mastodon/actions/store.js
@@ -1,4 +1,4 @@
-import { Iterable, fromJS } from 'immutable';
+import { fromJS, isIndexed } from 'immutable';
import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer';
@@ -9,8 +9,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
const convertState = rawState =>
fromJS(rawState, (k, v) =>
- Iterable.isIndexed(v) ? v.toList() : v.toMap());
-
+ isIndexed(v) ? v.toList() : v.toMap());
export function hydrateStore(rawState) {
return dispatch => {
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index a828900ec9..f9d784c2b4 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -11,7 +11,7 @@ import {
} from './announcements';
import { updateConversations } from './conversations';
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
-import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications';
+import { updateNotifications, updateEmojiReactions } from './notifications';
import { updateStatus } from './statuses';
import {
updateTimeline,
@@ -111,12 +111,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
// @ts-expect-error
dispatch(updateEmojiReactions(JSON.parse(data.payload)));
break;
- case 'notifications_merged':
- const state = getState();
- if (state.notifications.top || !state.notifications.mounted)
- dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
+ case 'notifications_merged': {
dispatch(refreshStaleNotificationGroups());
break;
+ }
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));
diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js
deleted file mode 100644
index 258ffa901d..0000000000
--- a/app/javascript/mastodon/actions/suggestions.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import api from '../api';
-
-import { fetchRelationships } from './accounts';
-import { importFetchedAccounts } from './importer';
-
-export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
-export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
-export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
-
-export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
-
-export function fetchSuggestions(withRelationships = false) {
- return (dispatch) => {
- dispatch(fetchSuggestionsRequest());
-
- api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
- dispatch(importFetchedAccounts(response.data.map(x => x.account)));
- dispatch(fetchSuggestionsSuccess(response.data));
-
- if (withRelationships) {
- dispatch(fetchRelationships(response.data.map(item => item.account.id)));
- }
- }).catch(error => dispatch(fetchSuggestionsFail(error)));
- };
-}
-
-export function fetchSuggestionsRequest() {
- return {
- type: SUGGESTIONS_FETCH_REQUEST,
- skipLoading: true,
- };
-}
-
-export function fetchSuggestionsSuccess(suggestions) {
- return {
- type: SUGGESTIONS_FETCH_SUCCESS,
- suggestions,
- skipLoading: true,
- };
-}
-
-export function fetchSuggestionsFail(error) {
- return {
- type: SUGGESTIONS_FETCH_FAIL,
- error,
- skipLoading: true,
- skipAlert: true,
- };
-}
-
-export const dismissSuggestion = accountId => (dispatch) => {
- dispatch({
- type: SUGGESTIONS_DISMISS,
- id: accountId,
- });
-
- api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
-};
diff --git a/app/javascript/mastodon/actions/suggestions.ts b/app/javascript/mastodon/actions/suggestions.ts
new file mode 100644
index 0000000000..0eadfa6b47
--- /dev/null
+++ b/app/javascript/mastodon/actions/suggestions.ts
@@ -0,0 +1,24 @@
+import {
+ apiGetSuggestions,
+ apiDeleteSuggestion,
+} from 'mastodon/api/suggestions';
+import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+
+export const fetchSuggestions = createDataLoadingThunk(
+ 'suggestions/fetch',
+ () => apiGetSuggestions(20),
+ (data, { dispatch }) => {
+ dispatch(importFetchedAccounts(data.map((x) => x.account)));
+ dispatch(fetchRelationships(data.map((x) => x.account.id)));
+
+ return data;
+ },
+);
+
+export const dismissSuggestion = createDataLoadingThunk(
+ 'suggestions/dismiss',
+ ({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
+);
diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js
deleted file mode 100644
index d18d7e514f..0000000000
--- a/app/javascript/mastodon/actions/tags.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import api, { getLinks } from '../api';
-
-export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
-export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
-export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
-
-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 HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
-export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
-export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
-
-export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
-export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
-export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
-
-export const fetchHashtag = name => (dispatch) => {
- dispatch(fetchHashtagRequest());
-
- api().get(`/api/v1/tags/${name}`).then(({ data }) => {
- dispatch(fetchHashtagSuccess(name, data));
- }).catch(err => {
- dispatch(fetchHashtagFail(err));
- });
-};
-
-export const fetchHashtagRequest = () => ({
- type: HASHTAG_FETCH_REQUEST,
-});
-
-export const fetchHashtagSuccess = (name, tag) => ({
- type: HASHTAG_FETCH_SUCCESS,
- name,
- tag,
-});
-
-export const fetchHashtagFail = error => ({
- type: HASHTAG_FETCH_FAIL,
- error,
-});
-
-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,
- };
-}
-
-export const followHashtag = name => (dispatch) => {
- dispatch(followHashtagRequest(name));
-
- api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
- dispatch(followHashtagSuccess(name, data));
- }).catch(err => {
- dispatch(followHashtagFail(name, err));
- });
-};
-
-export const followHashtagRequest = name => ({
- type: HASHTAG_FOLLOW_REQUEST,
- name,
-});
-
-export const followHashtagSuccess = (name, tag) => ({
- type: HASHTAG_FOLLOW_SUCCESS,
- name,
- tag,
-});
-
-export const followHashtagFail = (name, error) => ({
- type: HASHTAG_FOLLOW_FAIL,
- name,
- error,
-});
-
-export const unfollowHashtag = name => (dispatch) => {
- dispatch(unfollowHashtagRequest(name));
-
- api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
- dispatch(unfollowHashtagSuccess(name, data));
- }).catch(err => {
- dispatch(unfollowHashtagFail(name, err));
- });
-};
-
-export const unfollowHashtagRequest = name => ({
- type: HASHTAG_UNFOLLOW_REQUEST,
- name,
-});
-
-export const unfollowHashtagSuccess = (name, tag) => ({
- type: HASHTAG_UNFOLLOW_SUCCESS,
- name,
- tag,
-});
-
-export const unfollowHashtagFail = (name, error) => ({
- type: HASHTAG_UNFOLLOW_FAIL,
- name,
- error,
-});
diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts
new file mode 100644
index 0000000000..6dca32fd84
--- /dev/null
+++ b/app/javascript/mastodon/actions/tags_typed.ts
@@ -0,0 +1,17 @@
+import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags';
+import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+
+export const fetchHashtag = createDataLoadingThunk(
+ 'tags/fetch',
+ ({ tagId }: { tagId: string }) => apiGetTag(tagId),
+);
+
+export const followHashtag = createDataLoadingThunk(
+ 'tags/follow',
+ ({ tagId }: { tagId: string }) => apiFollowTag(tagId),
+);
+
+export const unfollowHashtag = createDataLoadingThunk(
+ 'tags/unfollow',
+ ({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
+);
diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts
index 51cbe0b695..a41b058d2c 100644
--- a/app/javascript/mastodon/api.ts
+++ b/app/javascript/mastodon/api.ts
@@ -1,4 +1,9 @@
-import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
+import type {
+ AxiosError,
+ AxiosResponse,
+ Method,
+ RawAxiosRequestHeaders,
+} from 'axios';
import axios from 'axios';
import LinkHeader from 'http-link-header';
@@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) {
- return axios.create({
+ const instance = axios.create({
transitional: {
clarifyTimeoutError: true,
},
@@ -60,6 +65,22 @@ export default function api(withAuthorization = true) {
},
],
});
+
+ instance.interceptors.response.use(
+ (response: AxiosResponse) => {
+ if (response.headers.deprecation) {
+ console.warn(
+ `Deprecated request: ${response.config.method} ${response.config.url}`,
+ );
+ }
+ return response;
+ },
+ (error: AxiosError) => {
+ return Promise.reject(error);
+ },
+ );
+
+ return instance;
}
type RequestParamsOrData = Record;
@@ -68,6 +89,7 @@ export async function apiRequest(
method: Method,
url: string,
args: {
+ signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
timeout?: number;
diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts
index bd1757e827..717010ba74 100644
--- a/app/javascript/mastodon/api/accounts.ts
+++ b/app/javascript/mastodon/api/accounts.ts
@@ -5,3 +5,16 @@ export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost(`v1/accounts/${id}/note`, {
comment: value,
});
+
+export const apiFollowAccount = (
+ id: string,
+ params?: {
+ reblogs: boolean;
+ },
+) =>
+ apiRequestPost(`v1/accounts/${id}/follow`, {
+ ...params,
+ });
+
+export const apiUnfollowAccount = (id: string) =>
+ apiRequestPost(`v1/accounts/${id}/unfollow`);
diff --git a/app/javascript/mastodon/api/antennas.ts b/app/javascript/mastodon/api/antennas.ts
new file mode 100644
index 0000000000..61fd84185d
--- /dev/null
+++ b/app/javascript/mastodon/api/antennas.ts
@@ -0,0 +1,143 @@
+import {
+ apiRequestPost,
+ apiRequestPut,
+ apiRequestGet,
+ apiRequestDelete,
+} from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type { ApiAntennaJSON } from 'mastodon/api_types/antennas';
+
+export const apiCreate = (antenna: Partial) =>
+ apiRequestPost('v1/antennas', antenna);
+
+export const apiUpdate = (antenna: Partial) =>
+ apiRequestPut(`v1/antennas/${antenna.id}`, antenna);
+
+export const apiGetAccounts = (antennaId: string) =>
+ apiRequestGet(`v1/antennas/${antennaId}/accounts`, {
+ limit: 0,
+ });
+
+export const apiGetExcludeAccounts = (antennaId: string) =>
+ apiRequestGet(`v1/antennas/${antennaId}/exclude_accounts`, {
+ limit: 0,
+ });
+
+export const apiGetDomains = (antennaId: string) =>
+ apiRequestGet<{ domains: string[]; exclude_domains: string[] }>(
+ `v1/antennas/${antennaId}/domains`,
+ {
+ limit: 0,
+ },
+ );
+
+export const apiAddDomain = (antennaId: string, domain: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/domains`, {
+ domains: [domain],
+ });
+
+export const apiRemoveDomain = (antennaId: string, domain: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/domains`, {
+ domains: [domain],
+ });
+
+export const apiAddExcludeDomain = (antennaId: string, domain: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/exclude_domains`, {
+ domains: [domain],
+ });
+
+export const apiRemoveExcludeDomain = (antennaId: string, domain: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/exclude_domains`, {
+ domains: [domain],
+ });
+
+export const apiGetTags = (antennaId: string) =>
+ apiRequestGet<{ tags: string[]; exclude_tags: string[] }>(
+ `v1/antennas/${antennaId}/tags`,
+ {
+ limit: 0,
+ },
+ );
+
+export const apiAddTag = (antennaId: string, tag: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/tags`, {
+ tags: [tag],
+ });
+
+export const apiRemoveTag = (antennaId: string, tag: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/tags`, {
+ tags: [tag],
+ });
+
+export const apiAddExcludeTag = (antennaId: string, tag: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/exclude_tags`, {
+ tags: [tag],
+ });
+
+export const apiRemoveExcludeTag = (antennaId: string, tag: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/exclude_tags`, {
+ tags: [tag],
+ });
+
+export const apiGetKeywords = (antennaId: string) =>
+ apiRequestGet<{ keywords: string[]; exclude_keywords: string[] }>(
+ `v1/antennas/${antennaId}/keywords`,
+ {
+ limit: 0,
+ },
+ );
+
+export const apiAddKeyword = (antennaId: string, keyword: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/keywords`, {
+ keywords: [keyword],
+ });
+
+export const apiRemoveKeyword = (antennaId: string, keyword: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/keywords`, {
+ keywords: [keyword],
+ });
+
+export const apiAddExcludeKeyword = (antennaId: string, keyword: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/exclude_keywords`, {
+ keywords: [keyword],
+ });
+
+export const apiRemoveExcludeKeyword = (antennaId: string, keyword: string) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/exclude_keywords`, {
+ keywords: [keyword],
+ });
+
+export const apiGetAccountAntennas = (accountId: string) =>
+ apiRequestGet(`v1/accounts/${accountId}/antennas`);
+
+export const apiAddAccountToAntenna = (antennaId: string, accountId: string) =>
+ apiRequestPost(`v1/antennas/${antennaId}/accounts`, {
+ account_ids: [accountId],
+ });
+
+export const apiRemoveAccountFromAntenna = (
+ antennaId: string,
+ accountId: string,
+) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/accounts`, {
+ account_ids: [accountId],
+ });
+
+export const apiGetExcludeAccountAntennas = (accountId: string) =>
+ apiRequestGet(`v1/accounts/${accountId}/exclude_antennas`);
+
+export const apiAddExcludeAccountToAntenna = (
+ antennaId: string,
+ accountId: string,
+) =>
+ apiRequestPost(`v1/antennas/${antennaId}/exclude_accounts`, {
+ account_ids: [accountId],
+ });
+
+export const apiRemoveExcludeAccountFromAntenna = (
+ antennaId: string,
+ accountId: string,
+) =>
+ apiRequestDelete(`v1/antennas/${antennaId}/exclude_accounts`, {
+ account_ids: [accountId],
+ });
diff --git a/app/javascript/mastodon/api/bookmark_categories.ts b/app/javascript/mastodon/api/bookmark_categories.ts
new file mode 100644
index 0000000000..d6d3394b3a
--- /dev/null
+++ b/app/javascript/mastodon/api/bookmark_categories.ts
@@ -0,0 +1,49 @@
+import {
+ apiRequestPost,
+ apiRequestPut,
+ apiRequestGet,
+ apiRequestDelete,
+} from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories';
+
+export const apiCreate = (bookmarkCategory: Partial) =>
+ apiRequestPost(
+ 'v1/bookmark_categories',
+ bookmarkCategory,
+ );
+
+export const apiUpdate = (bookmarkCategory: Partial) =>
+ apiRequestPut(
+ `v1/bookmark_categories/${bookmarkCategory.id}`,
+ bookmarkCategory,
+ );
+
+export const apiGetStatuses = (bookmarkCategoryId: string) =>
+ apiRequestGet(
+ `v1/bookmark_categories/${bookmarkCategoryId}/statuses`,
+ {
+ limit: 0,
+ },
+ );
+
+export const apiGetStatusBookmarkCategories = (accountId: string) =>
+ apiRequestGet(
+ `v1/statuses/${accountId}/bookmark_categories`,
+ );
+
+export const apiAddStatusToBookmarkCategory = (
+ bookmarkCategoryId: string,
+ statusId: string,
+) =>
+ apiRequestPost(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
+ status_ids: [statusId],
+ });
+
+export const apiRemoveStatusFromBookmarkCategory = (
+ bookmarkCategoryId: string,
+ statusId: string,
+) =>
+ apiRequestDelete(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
+ status_ids: [statusId],
+ });
diff --git a/app/javascript/mastodon/api/circles.ts b/app/javascript/mastodon/api/circles.ts
new file mode 100644
index 0000000000..04971e1e6b
--- /dev/null
+++ b/app/javascript/mastodon/api/circles.ts
@@ -0,0 +1,35 @@
+import {
+ apiRequestPost,
+ apiRequestPut,
+ apiRequestGet,
+ apiRequestDelete,
+} from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type { ApiCircleJSON } from 'mastodon/api_types/circles';
+
+export const apiCreate = (circle: Partial) =>
+ apiRequestPost('v1/circles', circle);
+
+export const apiUpdate = (circle: Partial) =>
+ apiRequestPut(`v1/circles/${circle.id}`, circle);
+
+export const apiGetAccounts = (circleId: string) =>
+ apiRequestGet(`v1/circles/${circleId}/accounts`, {
+ limit: 0,
+ });
+
+export const apiGetAccountCircles = (accountId: string) =>
+ apiRequestGet(`v1/accounts/${accountId}/circles`);
+
+export const apiAddAccountToCircle = (circleId: string, accountId: string) =>
+ apiRequestPost(`v1/circles/${circleId}/accounts`, {
+ account_ids: [accountId],
+ });
+
+export const apiRemoveAccountFromCircle = (
+ circleId: string,
+ accountId: string,
+) =>
+ apiRequestDelete(`v1/circles/${circleId}/accounts`, {
+ account_ids: [accountId],
+ });
diff --git a/app/javascript/mastodon/api/compose.ts b/app/javascript/mastodon/api/compose.ts
new file mode 100644
index 0000000000..757e9961c9
--- /dev/null
+++ b/app/javascript/mastodon/api/compose.ts
@@ -0,0 +1,7 @@
+import { apiRequestPut } from 'mastodon/api';
+import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
+
+export const apiUpdateMedia = (
+ id: string,
+ params?: { description?: string; focus?: string },
+) => apiRequestPut(`v1/media/${id}`, params);
diff --git a/app/javascript/mastodon/api/domain_blocks.ts b/app/javascript/mastodon/api/domain_blocks.ts
new file mode 100644
index 0000000000..4e153b0ee9
--- /dev/null
+++ b/app/javascript/mastodon/api/domain_blocks.ts
@@ -0,0 +1,13 @@
+import api, { getLinks } from 'mastodon/api';
+
+export const apiGetDomainBlocks = async (url?: string) => {
+ const response = await api().request({
+ method: 'GET',
+ url: url ?? '/api/v1/domain_blocks',
+ });
+
+ return {
+ domains: response.data,
+ links: getLinks(response),
+ };
+};
diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts
new file mode 100644
index 0000000000..764e8daab2
--- /dev/null
+++ b/app/javascript/mastodon/api/instance.ts
@@ -0,0 +1,15 @@
+import { apiRequestGet } from 'mastodon/api';
+import type {
+ ApiTermsOfServiceJSON,
+ ApiPrivacyPolicyJSON,
+} from 'mastodon/api_types/instance';
+
+export const apiGetTermsOfService = (version?: string) =>
+ apiRequestGet(
+ version
+ ? `v1/instance/terms_of_service/${version}`
+ : 'v1/instance/terms_of_service',
+ );
+
+export const apiGetPrivacyPolicy = () =>
+ apiRequestGet('v1/instance/privacy_policy');
diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts
new file mode 100644
index 0000000000..a5586eb6d4
--- /dev/null
+++ b/app/javascript/mastodon/api/lists.ts
@@ -0,0 +1,32 @@
+import {
+ apiRequestPost,
+ apiRequestPut,
+ apiRequestGet,
+ apiRequestDelete,
+} from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type { ApiListJSON } from 'mastodon/api_types/lists';
+
+export const apiCreate = (list: Partial) =>
+ apiRequestPost('v1/lists', list);
+
+export const apiUpdate = (list: Partial) =>
+ apiRequestPut(`v1/lists/${list.id}`, list);
+
+export const apiGetAccounts = (listId: string) =>
+ apiRequestGet(`v1/lists/${listId}/accounts`, {
+ limit: 0,
+ });
+
+export const apiGetAccountLists = (accountId: string) =>
+ apiRequestGet(`v1/accounts/${accountId}/lists`);
+
+export const apiAddAccountToList = (listId: string, accountId: string) =>
+ apiRequestPost(`v1/lists/${listId}/accounts`, {
+ account_ids: [accountId],
+ });
+
+export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
+ apiRequestDelete(`v1/lists/${listId}/accounts`, {
+ account_ids: [accountId],
+ });
diff --git a/app/javascript/mastodon/api/polls.ts b/app/javascript/mastodon/api/polls.ts
new file mode 100644
index 0000000000..cb659986f5
--- /dev/null
+++ b/app/javascript/mastodon/api/polls.ts
@@ -0,0 +1,10 @@
+import { apiRequestGet, apiRequestPost } from 'mastodon/api';
+import type { ApiPollJSON } from 'mastodon/api_types/polls';
+
+export const apiGetPoll = (pollId: string) =>
+ apiRequestGet(`/v1/polls/${pollId}`);
+
+export const apiPollVote = (pollId: string, choices: string[]) =>
+ apiRequestPost(`/v1/polls/${pollId}/votes`, {
+ choices,
+ });
diff --git a/app/javascript/mastodon/api/search.ts b/app/javascript/mastodon/api/search.ts
new file mode 100644
index 0000000000..79b0385fe8
--- /dev/null
+++ b/app/javascript/mastodon/api/search.ts
@@ -0,0 +1,16 @@
+import { apiRequestGet } from 'mastodon/api';
+import type {
+ ApiSearchType,
+ ApiSearchResultsJSON,
+} from 'mastodon/api_types/search';
+
+export const apiGetSearch = (params: {
+ q: string;
+ resolve?: boolean;
+ type?: ApiSearchType;
+ limit?: number;
+ offset?: number;
+}) =>
+ apiRequestGet('v2/search', {
+ ...params,
+ });
diff --git a/app/javascript/mastodon/api/suggestions.ts b/app/javascript/mastodon/api/suggestions.ts
new file mode 100644
index 0000000000..d4817698cc
--- /dev/null
+++ b/app/javascript/mastodon/api/suggestions.ts
@@ -0,0 +1,8 @@
+import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
+import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
+
+export const apiGetSuggestions = (limit: number) =>
+ apiRequestGet('v2/suggestions', { limit });
+
+export const apiDeleteSuggestion = (accountId: string) =>
+ apiRequestDelete(`v1/suggestions/${accountId}`);
diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts
new file mode 100644
index 0000000000..4b111def81
--- /dev/null
+++ b/app/javascript/mastodon/api/tags.ts
@@ -0,0 +1,23 @@
+import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
+import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
+
+export const apiGetTag = (tagId: string) =>
+ apiRequestGet(`v1/tags/${tagId}`);
+
+export const apiFollowTag = (tagId: string) =>
+ apiRequestPost(`v1/tags/${tagId}/follow`);
+
+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/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts
index a9464b1a47..9d7974eda0 100644
--- a/app/javascript/mastodon/api_types/accounts.ts
+++ b/app/javascript/mastodon/api_types/accounts.ts
@@ -39,13 +39,13 @@ export interface ApiServerFeaturesJSON {
}
// See app/serializers/rest/account_serializer.rb
-export interface ApiAccountJSON {
+export interface BaseApiAccountJSON {
acct: string;
avatar: string;
avatar_static: string;
bot: boolean;
created_at: string;
- discoverable: boolean;
+ discoverable?: boolean;
indexable: boolean;
display_name: string;
emojis: ApiCustomEmojiJSON[];
@@ -74,3 +74,12 @@ export interface ApiAccountJSON {
memorial?: boolean;
hide_collections: boolean;
}
+
+// See app/serializers/rest/muted_account_serializer.rb
+export interface ApiMutedAccountJSON extends BaseApiAccountJSON {
+ mute_expires_at?: string | null;
+}
+
+// For now, we have the same type representing both `Account` and `MutedAccount`
+// objects, but we should refactor this in the future.
+export type ApiAccountJSON = ApiMutedAccountJSON;
diff --git a/app/javascript/mastodon/api_types/antennas.ts b/app/javascript/mastodon/api_types/antennas.ts
new file mode 100644
index 0000000000..a2a8a997ba
--- /dev/null
+++ b/app/javascript/mastodon/api_types/antennas.ts
@@ -0,0 +1,17 @@
+// See app/serializers/rest/antenna_serializer.rb
+
+import type { ApiListJSON } from './lists';
+
+export interface ApiAntennaJSON {
+ id: string;
+ title: string;
+ stl: boolean;
+ ltl: boolean;
+ insert_feeds: boolean;
+ with_media_only: boolean;
+ ignore_reblog: boolean;
+ favourite: boolean;
+ list: ApiListJSON | null;
+
+ list_id: string | undefined;
+}
diff --git a/app/javascript/mastodon/api_types/bookmark_categories.ts b/app/javascript/mastodon/api_types/bookmark_categories.ts
new file mode 100644
index 0000000000..5407b6b125
--- /dev/null
+++ b/app/javascript/mastodon/api_types/bookmark_categories.ts
@@ -0,0 +1,6 @@
+// See app/serializers/rest/bookmark_category_serializer.rb
+
+export interface ApiBookmarkCategoryJSON {
+ id: string;
+ title: string;
+}
diff --git a/app/javascript/mastodon/api_types/circles.ts b/app/javascript/mastodon/api_types/circles.ts
new file mode 100644
index 0000000000..9905d480b8
--- /dev/null
+++ b/app/javascript/mastodon/api_types/circles.ts
@@ -0,0 +1,6 @@
+// See app/serializers/rest/circle_serializer.rb
+
+export interface ApiCircleJSON {
+ id: string;
+ title: string;
+}
diff --git a/app/javascript/mastodon/api_types/dummy_types.ts b/app/javascript/mastodon/api_types/dummy_types.ts
deleted file mode 100644
index 1f8c4db6f2..0000000000
--- a/app/javascript/mastodon/api_types/dummy_types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// A similar definition will eventually be added in the main house. These definitions will replace it.
-
-export interface ApiListJSON_KmyDummy {
- id: string;
- title: string;
- exclusive: boolean;
- notify: boolean;
-
- // replies_policy
- // antennas
-}
diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts
new file mode 100644
index 0000000000..3a29684b70
--- /dev/null
+++ b/app/javascript/mastodon/api_types/instance.ts
@@ -0,0 +1,11 @@
+export interface ApiTermsOfServiceJSON {
+ effective_date: string;
+ effective: boolean;
+ succeeded_by: string | null;
+ content: string;
+}
+
+export interface ApiPrivacyPolicyJSON {
+ updated_at: string;
+ content: string;
+}
diff --git a/app/javascript/mastodon/api_types/lists.ts b/app/javascript/mastodon/api_types/lists.ts
new file mode 100644
index 0000000000..bc32b33883
--- /dev/null
+++ b/app/javascript/mastodon/api_types/lists.ts
@@ -0,0 +1,15 @@
+// See app/serializers/rest/list_serializer.rb
+
+import type { ApiAntennaJSON } from './antennas';
+
+export type RepliesPolicyType = 'list' | 'followed' | 'none';
+
+export interface ApiListJSON {
+ id: string;
+ title: string;
+ exclusive: boolean;
+ replies_policy: RepliesPolicyType;
+ notify: boolean;
+ favourite: boolean;
+ antennas?: ApiAntennaJSON[];
+}
diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts
index 89ce9ee497..41daed25ad 100644
--- a/app/javascript/mastodon/api_types/notifications.ts
+++ b/app/javascript/mastodon/api_types/notifications.ts
@@ -3,7 +3,7 @@
import type { AccountWarningAction } from 'mastodon/models/notification_group';
import type { ApiAccountJSON } from './accounts';
-import type { ApiListJSON_KmyDummy } from './dummy_types';
+import type { ApiListJSON } from './lists';
import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
@@ -24,6 +24,7 @@ export const allNotificationTypes = [
'admin.report',
'moderation_warning',
'severed_relationships',
+ 'annual_report',
];
export type NotificationWithStatusType =
@@ -44,7 +45,8 @@ export type NotificationType =
| 'moderation_warning'
| 'severed_relationships'
| 'admin.sign_up'
- | 'admin.report';
+ | 'admin.report'
+ | 'annual_report';
export interface NotifyEmojiReactionJSON {
name: string;
@@ -69,7 +71,7 @@ export interface BaseNotificationJSON {
group_key: string;
account: ApiAccountJSON;
emoji_reaction?: NotifyEmojiReactionJSON;
- list?: ApiListJSON_KmyDummy;
+ list?: ApiListJSON;
}
export interface BaseNotificationGroupJSON {
@@ -82,7 +84,7 @@ export interface BaseNotificationGroupJSON {
page_min_id?: string;
page_max_id?: string;
emoji_reaction_groups?: NotificationEmojiReactionGroupJSON[];
- list?: ApiListJSON_KmyDummy;
+ list?: ApiListJSON;
}
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
@@ -158,6 +160,15 @@ interface AccountRelationshipSeveranceNotificationJSON
event: ApiAccountRelationshipSeveranceEventJSON;
}
+export interface ApiAnnualReportEventJSON {
+ year: string;
+}
+
+interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON {
+ type: 'annual_report';
+ annual_report: ApiAnnualReportEventJSON;
+}
+
export type ApiNotificationJSON =
| SimpleNotificationJSON
| ReportNotificationJSON
@@ -170,7 +181,8 @@ export type ApiNotificationGroupJSON =
| ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON
- | ModerationWarningNotificationGroupJSON;
+ | ModerationWarningNotificationGroupJSON
+ | AnnualReportNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[];
diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts
index 8181f7b813..891a2faba7 100644
--- a/app/javascript/mastodon/api_types/polls.ts
+++ b/app/javascript/mastodon/api_types/polls.ts
@@ -13,11 +13,11 @@ export interface ApiPollJSON {
expired: boolean;
multiple: boolean;
votes_count: number;
- voters_count: number;
+ voters_count: number | null;
options: ApiPollOptionJSON[];
emojis: ApiCustomEmojiJSON[];
- voted: boolean;
- own_votes: number[];
+ voted?: boolean;
+ own_votes?: number[];
}
diff --git a/app/javascript/mastodon/api_types/search.ts b/app/javascript/mastodon/api_types/search.ts
new file mode 100644
index 0000000000..795cbb2b41
--- /dev/null
+++ b/app/javascript/mastodon/api_types/search.ts
@@ -0,0 +1,11 @@
+import type { ApiAccountJSON } from './accounts';
+import type { ApiStatusJSON } from './statuses';
+import type { ApiHashtagJSON } from './tags';
+
+export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
+
+export interface ApiSearchResultsJSON {
+ accounts: ApiAccountJSON[];
+ statuses: ApiStatusJSON[];
+ hashtags: ApiHashtagJSON[];
+}
diff --git a/app/javascript/mastodon/api_types/suggestions.ts b/app/javascript/mastodon/api_types/suggestions.ts
new file mode 100644
index 0000000000..7d91daf901
--- /dev/null
+++ b/app/javascript/mastodon/api_types/suggestions.ts
@@ -0,0 +1,13 @@
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+
+export type ApiSuggestionSourceJSON =
+ | 'featured'
+ | 'most_followed'
+ | 'most_interactions'
+ | 'similar_to_recently_followed'
+ | 'friends_of_friends';
+
+export interface ApiSuggestionJSON {
+ sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
+ account: ApiAccountJSON;
+}
diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts
new file mode 100644
index 0000000000..0c16c8bd28
--- /dev/null
+++ b/app/javascript/mastodon/api_types/tags.ts
@@ -0,0 +1,13 @@
+interface ApiHistoryJSON {
+ day: string;
+ accounts: string;
+ uses: string;
+}
+
+export interface ApiHashtagJSON {
+ id: string;
+ name: string;
+ url: string;
+ history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
+ following?: boolean;
+}
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
deleted file mode 100644
index 6204dcdf35..0000000000
--- a/app/javascript/mastodon/components/account.jsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import PropTypes from 'prop-types';
-import { useCallback } from 'react';
-
-import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
-
-import classNames from 'classnames';
-import { Link } from 'react-router-dom';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
-import { EmptyAccount } from 'mastodon/components/empty_account';
-import { ShortNumber } from 'mastodon/components/short_number';
-import { VerifiedBadge } from 'mastodon/components/verified_badge';
-
-import DropdownMenuContainer from '../containers/dropdown_menu_container';
-import { me } from '../initial_state';
-
-import { Avatar } from './avatar';
-import { Button } from './button';
-import { FollowersCounter } from './counters';
-import { DisplayName } from './display_name';
-import { RelativeTimestamp } from './relative_timestamp';
-
-const messages = defineMessages({
- follow: { id: 'account.follow', defaultMessage: 'Follow' },
- unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
- unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
- unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
- mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
- unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
- mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
- block: { id: 'account.block_short', defaultMessage: 'Block' },
- more: { id: 'status.more', defaultMessage: 'More' },
-});
-
-const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, hideButtons, minimal, defaultAction, children, withBio }) => {
- const intl = useIntl();
-
- const handleFollow = useCallback(() => {
- onFollow(account);
- }, [onFollow, account]);
-
- const handleBlock = useCallback(() => {
- onBlock(account);
- }, [onBlock, account]);
-
- const handleMute = useCallback(() => {
- onMute(account);
- }, [onMute, account]);
-
- const handleMuteNotifications = useCallback(() => {
- onMuteNotifications(account, true);
- }, [onMuteNotifications, account]);
-
- const handleUnmuteNotifications = useCallback(() => {
- onMuteNotifications(account, false);
- }, [onMuteNotifications, account]);
-
- if (!account) {
- return ;
- }
-
- if (hidden) {
- return (
- <>
- {account.get('display_name')}
- {account.get('username')}
- >
- );
- }
-
- let buttons;
-
- if (!hideButtons && account.get('id') !== me && account.get('relationship', null) !== null) {
- const following = account.getIn(['relationship', 'following']);
- const requested = account.getIn(['relationship', 'requested']);
- const blocking = account.getIn(['relationship', 'blocking']);
- const muting = account.getIn(['relationship', 'muting']);
-
- if (requested) {
- buttons = ;
- } else if (blocking) {
- buttons = ;
- } else if (muting) {
- let menu;
-
- if (account.getIn(['relationship', 'muting_notifications'])) {
- menu = [{ text: intl.formatMessage(messages.unmute_notifications), action: handleUnmuteNotifications }];
- } else {
- menu = [{ text: intl.formatMessage(messages.mute_notifications), action: handleMuteNotifications }];
- }
-
- buttons = (
- <>
-
-
-
- >
- );
- } else if (defaultAction === 'mute') {
- buttons = ;
- } else if (defaultAction === 'block') {
- buttons = ;
- } else if (!account.get('suspended') && !account.get('moved') || following) {
- buttons = ;
- }
- }
-
- let muteTimeRemaining;
-
- if (account.get('mute_expires_at')) {
- muteTimeRemaining = <>· >;
- }
-
- let verification;
-
- const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
-
- if (firstVerifiedField) {
- verification = ;
- }
-
- return (
-
-
-
-
-
-
-
- {!minimal && (
-
- {verification} {muteTimeRemaining}
-
- )}
-
-
-
- {!minimal && children && (
-
-
- {children}
-
-
- {buttons}
-
-
- )}
- {!minimal && !children && (
-
- {buttons}
-
- )}
-
-
- {withBio && (account.get('note').length > 0 ? (
-
- ) : (
-
- ))}
-
- );
-};
-
-Account.propTypes = {
- size: PropTypes.number,
- account: ImmutablePropTypes.record,
- onFollow: PropTypes.func,
- onBlock: PropTypes.func,
- onMute: PropTypes.func,
- onMuteNotifications: PropTypes.func,
- hidden: PropTypes.bool,
- hideButtons: PropTypes.bool,
- minimal: PropTypes.bool,
- defaultAction: PropTypes.string,
- withBio: PropTypes.bool,
- children: PropTypes.any,
-};
-
-export default Account;
diff --git a/app/javascript/mastodon/components/account.tsx b/app/javascript/mastodon/components/account.tsx
new file mode 100644
index 0000000000..c6c2204085
--- /dev/null
+++ b/app/javascript/mastodon/components/account.tsx
@@ -0,0 +1,299 @@
+import type { ReactNode } from 'react';
+import type React from 'react';
+import { useCallback, useMemo } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+import { Link } from 'react-router-dom';
+
+import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
+import {
+ blockAccount,
+ unblockAccount,
+ muteAccount,
+ unmuteAccount,
+} from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { initMuteModal } from 'mastodon/actions/mutes';
+import { Avatar } from 'mastodon/components/avatar';
+import { Button } from 'mastodon/components/button';
+import { FollowersCounter } from 'mastodon/components/counters';
+import { DisplayName } from 'mastodon/components/display_name';
+import { Dropdown } from 'mastodon/components/dropdown_menu';
+import { FollowButton } from 'mastodon/components/follow_button';
+import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+import { ShortNumber } from 'mastodon/components/short_number';
+import { Skeleton } from 'mastodon/components/skeleton';
+import { VerifiedBadge } from 'mastodon/components/verified_badge';
+import type { MenuItem } from 'mastodon/models/dropdown_menu';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ cancel_follow_request: {
+ id: 'account.cancel_follow_request',
+ defaultMessage: 'Withdraw follow request',
+ },
+ unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
+ unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+ mute_notifications: {
+ id: 'account.mute_notifications_short',
+ defaultMessage: 'Mute notifications',
+ },
+ unmute_notifications: {
+ id: 'account.unmute_notifications_short',
+ defaultMessage: 'Unmute notifications',
+ },
+ mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
+ block: { id: 'account.block_short', defaultMessage: 'Block' },
+ more: { id: 'status.more', defaultMessage: 'More' },
+ addToLists: {
+ id: 'account.add_or_remove_from_list',
+ defaultMessage: 'Add or Remove from lists',
+ },
+ openOriginalPage: {
+ id: 'account.open_original_page',
+ defaultMessage: 'Open original page',
+ },
+});
+
+export const Account: React.FC<{
+ size?: number;
+ id: string;
+ hidden?: boolean;
+ minimal?: boolean;
+ defaultAction?: 'block' | 'mute';
+ withBio?: boolean;
+ hideButtons?: boolean;
+ children?: ReactNode;
+}> = ({
+ id,
+ size = 46,
+ hidden,
+ minimal,
+ defaultAction,
+ withBio,
+ hideButtons,
+ children,
+}) => {
+ const intl = useIntl();
+ const account = useAppSelector((state) => state.accounts.get(id));
+ const relationship = useAppSelector((state) => state.relationships.get(id));
+ const dispatch = useAppDispatch();
+ const accountUrl = account?.url;
+
+ const handleBlock = useCallback(() => {
+ if (relationship?.blocking) {
+ dispatch(unblockAccount(id));
+ } else {
+ dispatch(blockAccount(id));
+ }
+ }, [dispatch, id, relationship]);
+
+ const handleMute = useCallback(() => {
+ if (relationship?.muting) {
+ dispatch(unmuteAccount(id));
+ } else {
+ dispatch(initMuteModal(account));
+ }
+ }, [dispatch, id, account, relationship]);
+
+ const menu = useMemo(() => {
+ let arr: MenuItem[] = [];
+
+ if (defaultAction === 'mute') {
+ const handleMuteNotifications = () => {
+ dispatch(muteAccount(id, true));
+ };
+
+ const handleUnmuteNotifications = () => {
+ dispatch(muteAccount(id, false));
+ };
+
+ arr = [
+ {
+ text: intl.formatMessage(
+ relationship?.muting_notifications
+ ? messages.unmute_notifications
+ : messages.mute_notifications,
+ ),
+ action: relationship?.muting_notifications
+ ? handleUnmuteNotifications
+ : handleMuteNotifications,
+ },
+ ];
+ } else if (defaultAction !== 'block') {
+ const handleAddToLists = () => {
+ dispatch(
+ openModal({
+ modalType: 'LIST_ADDER',
+ modalProps: {
+ accountId: id,
+ },
+ }),
+ );
+ };
+
+ arr = [
+ {
+ text: intl.formatMessage(messages.addToLists),
+ action: handleAddToLists,
+ },
+ ];
+
+ if (accountUrl) {
+ arr.unshift(
+ {
+ text: intl.formatMessage(messages.openOriginalPage),
+ href: accountUrl,
+ },
+ null,
+ );
+ }
+ }
+
+ return arr;
+ }, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
+
+ if (hidden) {
+ return (
+ <>
+ {account?.display_name}
+ {account?.username}
+ >
+ );
+ }
+
+ let button: React.ReactNode, dropdown: React.ReactNode;
+
+ if (menu.length > 0) {
+ dropdown = (
+
+ );
+ }
+
+ if (defaultAction === 'block') {
+ button = (
+
+ );
+ } else if (defaultAction === 'mute') {
+ button = (
+
+ );
+ } else {
+ button = ;
+ }
+
+ if (hideButtons) {
+ button = null;
+ }
+
+ let muteTimeRemaining: React.ReactNode;
+
+ if (account?.mute_expires_at) {
+ muteTimeRemaining = (
+ <>
+ ·
+ >
+ );
+ }
+
+ let verification: React.ReactNode;
+
+ const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
+
+ if (firstVerifiedField) {
+ verification = ;
+ }
+
+ return (
+
+
+
+
+ {account ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {!minimal && (
+
+ {account ? (
+ <>
+ {' '}
+ {verification} {muteTimeRemaining}
+ >
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {!minimal && children && (
+
+
{children}
+
+ {dropdown}
+ {button}
+
+
+ )}
+ {!minimal && !children && (
+
+ {dropdown}
+ {button}
+
+ )}
+
+
+ {account &&
+ withBio &&
+ (account.note.length > 0 ? (
+
+ ) : (
+
+
+
+ ))}
+
+ );
+};
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx
index 9d523c7402..301ffcbb24 100644
--- a/app/javascript/mastodon/components/account_bio.tsx
+++ b/app/javascript/mastodon/components/account_bio.tsx
@@ -1,4 +1,4 @@
-import { useLinks } from 'mastodon/../hooks/useLinks';
+import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{
note: string;
diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx
index e297f99e3a..4ce55f7896 100644
--- a/app/javascript/mastodon/components/account_fields.tsx
+++ b/app/javascript/mastodon/components/account_fields.tsx
@@ -1,8 +1,8 @@
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
-import { useLinks } from 'mastodon/../hooks/useLinks';
import { Icon } from 'mastodon/components/icon';
+import { useLinks } from 'mastodon/hooks/useLinks';
import type { Account } from 'mastodon/models/account';
export const AccountFields: React.FC<{
diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx
new file mode 100644
index 0000000000..26749fa103
--- /dev/null
+++ b/app/javascript/mastodon/components/alerts_controller.tsx
@@ -0,0 +1,105 @@
+import { useState, useEffect } from 'react';
+
+import { useIntl } from 'react-intl';
+import type { IntlShape } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { dismissAlert } from 'mastodon/actions/alerts';
+import type {
+ Alert,
+ TranslatableString,
+ TranslatableValues,
+} from 'mastodon/models/alert';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+const formatIfNeeded = (
+ intl: IntlShape,
+ message: TranslatableString,
+ values?: TranslatableValues,
+) => {
+ if (typeof message === 'object') {
+ return intl.formatMessage(message, values);
+ }
+
+ return message;
+};
+
+const Alert: React.FC<{
+ alert: Alert;
+ dismissAfter: number;
+}> = ({
+ alert: { key, title, message, values, action, onClick },
+ dismissAfter,
+}) => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+ const [active, setActive] = useState(false);
+
+ useEffect(() => {
+ const setActiveTimeout = setTimeout(() => {
+ setActive(true);
+ }, 1);
+
+ return () => {
+ clearTimeout(setActiveTimeout);
+ };
+ }, []);
+
+ useEffect(() => {
+ const dismissTimeout = setTimeout(() => {
+ setActive(false);
+
+ // Allow CSS transition to finish before removing from the DOM
+ setTimeout(() => {
+ dispatch(dismissAlert({ key }));
+ }, 500);
+ }, dismissAfter);
+
+ return () => {
+ clearTimeout(dismissTimeout);
+ };
+ }, [dispatch, setActive, key, dismissAfter]);
+
+ return (
+
+
+ {title && (
+
+ {formatIfNeeded(intl, title, values)}
+
+ )}
+
+
+ {formatIfNeeded(intl, message, values)}
+
+
+ {action && (
+
+ )}
+
+
+ );
+};
+
+export const AlertsController: React.FC = () => {
+ const alerts = useAppSelector((state) => state.alerts);
+
+ if (alerts.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {alerts.map((alert, idx) => (
+
+ ))}
+
+ );
+};
diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx
index 99bec1ee51..701cfbe8b4 100644
--- a/app/javascript/mastodon/components/alt_text_badge.tsx
+++ b/app/javascript/mastodon/components/alt_text_badge.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef } from 'react';
+import { useState, useCallback, useRef, useId } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -8,12 +8,15 @@ import type {
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
+import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
+
const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const AltTextBadge: React.FC<{
description: string;
}> = ({ description }) => {
+ const accessibilityId = useId();
const anchorRef = useRef(null);
const [open, setOpen] = useState(false);
@@ -25,12 +28,16 @@ export const AltTextBadge: React.FC<{
setOpen(false);
}, [setOpen]);
+ const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose);
+
return (
<>
@@ -47,9 +54,12 @@ export const AltTextBadge: React.FC<{
>
{({ props }) => (
-
diff --git a/app/javascript/mastodon/components/filter_warning.tsx b/app/javascript/mastodon/components/filter_warning.tsx
index 4305e43038..5eaaac4ba3 100644
--- a/app/javascript/mastodon/components/filter_warning.tsx
+++ b/app/javascript/mastodon/components/filter_warning.tsx
@@ -10,13 +10,16 @@ export const FilterWarning: React.FC<{
{chunks},
+ }}
/>
diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx
index 4414c75a84..f21ad60240 100644
--- a/app/javascript/mastodon/components/follow_button.tsx
+++ b/app/javascript/mastodon/components/follow_button.tsx
@@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
import { useIdentity } from '@/mastodon/identity_context';
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
@@ -14,13 +16,13 @@ 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<{
accountId?: string;
-}> = ({ accountId }) => {
+ compact?: boolean;
+}> = ({ accountId, compact }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -52,7 +54,7 @@ export const FollowButton: React.FC<{
);
}
- if (!relationship) return;
+ if (!relationship || !accountId) return;
if (accountId === me) {
return;
@@ -70,15 +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 &&
- isShowItem('relationships') &&
- relationship.followed_by
- ) {
- label = intl.formatMessage(messages.mutual);
} else if (relationship.following || relationship.requested) {
label = intl.formatMessage(messages.unfollow);
} else if (relationship.followed_by && isShowItem('relationships')) {
@@ -92,8 +88,10 @@ export const FollowButton: React.FC<{
{label}
@@ -103,8 +101,14 @@ export const FollowButton: React.FC<{
return (
- );
-};
+GIFV.displayName = 'GIFV';
diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx
index 8963e4a40d..346c95183f 100644
--- a/app/javascript/mastodon/components/hashtag.tsx
+++ b/app/javascript/mastodon/components/hashtag.tsx
@@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
+import type { Hashtag as HashtagType } from 'mastodon/models/tags';
interface SilentErrorBoundaryProps {
children: React.ReactNode;
@@ -80,15 +81,32 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
/>
);
+export const CompatibilityHashtag: React.FC<{
+ hashtag: HashtagType;
+}> = ({ hashtag }) => (
+ (day.uses as unknown as number) * 1)
+ .reverse()}
+ />
+);
+
export interface HashtagProps {
className?: string;
description?: React.ReactNode;
history?: number[];
name: string;
- people: number;
+ people?: number;
to: string;
uses?: number;
withGraph?: boolean;
+ children?: React.ReactNode;
}
export const Hashtag: React.FC = ({
@@ -100,6 +118,7 @@ export const Hashtag: React.FC = ({
className,
description,
withGraph = true,
+ children,
}) => (
@@ -134,12 +153,14 @@ export const Hashtag: React.FC = ({
0)}
+ data={history ?? Array.from(Array(7)).map(() => 0)}
>
)}
+
+ {children &&
{children}
}
);
diff --git a/app/javascript/mastodon/components/hashtag_bar.tsx b/app/javascript/mastodon/components/hashtag_bar.tsx
index 9e1d74bb74..ce8f17ddb9 100644
--- a/app/javascript/mastodon/components/hashtag_bar.tsx
+++ b/app/javascript/mastodon/components/hashtag_bar.tsx
@@ -20,6 +20,7 @@ export type StatusLike = Record<{
contentHTML: string;
media_attachments: List;
spoiler_text?: string;
+ account: Record<{ id: string }>;
}>;
function normalizeHashtag(hashtag: string) {
@@ -195,19 +196,36 @@ export function getHashtagBarForStatus(status: StatusLike) {
return {
statusContentProps,
- hashtagBar: ,
+ hashtagBar: (
+
+ ),
};
}
-export function getFeaturedHashtagBar(acct: string, tags: string[]) {
- return ;
+export function getFeaturedHashtagBar(
+ accountId: string,
+ acct: string,
+ tags: string[],
+) {
+ return (
+
+ );
}
const HashtagBar: React.FC<{
hashtags: string[];
+ accountId: string;
acct?: string;
defaultExpanded?: boolean;
-}> = ({ hashtags, acct, defaultExpanded }) => {
+}> = ({ hashtags, accountId, acct, defaultExpanded }) => {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
setExpanded(true);
@@ -228,6 +246,7 @@ const HashtagBar: React.FC<{
#{hashtag}
diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx
index 057ef1aaed..38c3306f30 100644
--- a/app/javascript/mastodon/components/hover_card_controller.tsx
+++ b/app/javascript/mastodon/components/hover_card_controller.tsx
@@ -8,8 +8,8 @@ import type {
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
-import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
+import { useTimeout } from 'mastodon/hooks/useTimeout';
const offset = [-12, 4] as OffsetValue;
const enterDelay = 750;
diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx
index b7cac35960..7e0b3e7a22 100644
--- a/app/javascript/mastodon/components/icon_button.tsx
+++ b/app/javascript/mastodon/components/icon_button.tsx
@@ -1,4 +1,4 @@
-import { PureComponent, createRef } from 'react';
+import { useState, useEffect, useCallback, forwardRef } from 'react';
import classNames from 'classnames';
@@ -15,101 +15,110 @@ interface Props {
onMouseDown?: React.MouseEventHandler;
onKeyDown?: React.KeyboardEventHandler;
onKeyPress?: React.KeyboardEventHandler;
- active: boolean;
+ active?: boolean;
expanded?: boolean;
style?: React.CSSProperties;
activeStyle?: React.CSSProperties;
- disabled: boolean;
+ disabled?: boolean;
inverted?: boolean;
- animate: boolean;
- overlay: boolean;
- tabIndex: number;
+ animate?: boolean;
+ overlay?: boolean;
+ tabIndex?: number;
counter?: number;
href?: string;
- ariaHidden: boolean;
+ ariaHidden?: boolean;
data_id?: string;
}
-interface States {
- activate: boolean;
- deactivate: boolean;
-}
-export class IconButton extends PureComponent {
- buttonRef = createRef();
- static defaultProps = {
- active: false,
- disabled: false,
- animate: false,
- overlay: false,
- tabIndex: 0,
- ariaHidden: false,
- };
-
- state = {
- activate: false,
- deactivate: false,
- };
-
- UNSAFE_componentWillReceiveProps(nextProps: Props) {
- if (!nextProps.animate) return;
-
- if (this.props.active && !nextProps.active) {
- this.setState({ activate: false, deactivate: true });
- } else if (!this.props.active && nextProps.active) {
- this.setState({ activate: true, deactivate: false });
- }
- }
-
- handleClick: React.MouseEventHandler = (e) => {
- e.preventDefault();
-
- if (!this.props.disabled && this.props.onClick != null) {
- this.props.onClick(e);
- }
- };
-
- handleKeyPress: React.KeyboardEventHandler = (e) => {
- if (this.props.onKeyPress && !this.props.disabled) {
- this.props.onKeyPress(e);
- }
- };
-
- handleMouseDown: React.MouseEventHandler = (e) => {
- if (!this.props.disabled && this.props.onMouseDown) {
- this.props.onMouseDown(e);
- }
- };
-
- handleKeyDown: React.KeyboardEventHandler = (e) => {
- if (!this.props.disabled && this.props.onKeyDown) {
- this.props.onKeyDown(e);
- }
- };
-
- render() {
- const style = {
- ...this.props.style,
- ...(this.props.active ? this.props.activeStyle : {}),
- };
-
- const {
- active,
+export const IconButton = forwardRef(
+ (
+ {
className,
- disabled,
expanded,
icon,
iconComponent,
inverted,
- overlay,
- tabIndex,
title,
counter,
href,
- ariaHidden,
- data_id,
- } = this.props;
+ style,
+ activeStyle,
+ onClick,
+ onKeyDown,
+ onKeyPress,
+ onMouseDown,
+ active = false,
+ disabled = false,
+ animate = false,
+ overlay = false,
+ tabIndex = 0,
+ ariaHidden = false,
+ data_id = undefined,
+ },
+ buttonRef,
+ ) => {
+ const [activate, setActivate] = useState(false);
+ const [deactivate, setDeactivate] = useState(false);
- const { activate, deactivate } = this.state;
+ useEffect(() => {
+ if (!animate) {
+ return;
+ }
+
+ if (activate && !active) {
+ setActivate(false);
+ setDeactivate(true);
+ } else if (!activate && active) {
+ setActivate(true);
+ setDeactivate(false);
+ }
+ }, [setActivate, setDeactivate, animate, active, activate]);
+
+ const handleClick: React.MouseEventHandler = useCallback(
+ (e) => {
+ e.preventDefault();
+
+ if (!disabled) {
+ onClick?.(e);
+ }
+ },
+ [disabled, onClick],
+ );
+
+ const handleKeyPress: React.KeyboardEventHandler =
+ useCallback(
+ (e) => {
+ if (!disabled) {
+ onKeyPress?.(e);
+ }
+ },
+ [disabled, onKeyPress],
+ );
+
+ const handleMouseDown: React.MouseEventHandler =
+ useCallback(
+ (e) => {
+ if (!disabled) {
+ onMouseDown?.(e);
+ }
+ },
+ [disabled, onMouseDown],
+ );
+
+ const handleKeyDown: React.KeyboardEventHandler =
+ useCallback(
+ (e) => {
+ if (!disabled) {
+ onKeyDown?.(e);
+ }
+ },
+ [disabled, onKeyDown],
+ );
+
+ const buttonStyle = {
+ ...style,
+ ...(active ? activeStyle : {}),
+ };
const classes = classNames(className, 'icon-button', {
active,
@@ -148,18 +157,20 @@ export class IconButton extends PureComponent {
aria-hidden={ariaHidden}
title={title}
className={classes}
- onClick={this.handleClick}
- onMouseDown={this.handleMouseDown}
- onKeyDown={this.handleKeyDown}
- onKeyPress={this.handleKeyPress}
- style={style}
+ onClick={handleClick}
+ onMouseDown={handleMouseDown}
+ onKeyDown={handleKeyDown}
+ onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
+ style={buttonStyle}
tabIndex={tabIndex}
disabled={disabled}
data-id={data_id}
- ref={this.buttonRef}
+ ref={buttonRef}
>
{contents}
);
- }
-}
+ },
+);
+
+IconButton.displayName = 'IconButton';
diff --git a/app/javascript/mastodon/components/load_gap.tsx b/app/javascript/mastodon/components/load_gap.tsx
index 544b5e1461..6cbdee6ce5 100644
--- a/app/javascript/mastodon/components/load_gap.tsx
+++ b/app/javascript/mastodon/components/load_gap.tsx
@@ -1,9 +1,10 @@
-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Icon } from 'mastodon/components/icon';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
@@ -17,10 +18,12 @@ interface Props {
export const LoadGap = ({ disabled, param, onClick }: Props) => {
const intl = useIntl();
+ const [loading, setLoading] = useState(false);
const handleClick = useCallback(() => {
+ setLoading(true);
onClick(param);
- }, [param, onClick]);
+ }, [setLoading, param, onClick]);
return (
);
};
diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx
index 443d2cb8bd..12cf381e5e 100644
--- a/app/javascript/mastodon/components/media_gallery.jsx
+++ b/app/javascript/mastodon/components/media_gallery.jsx
@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash';
+import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@@ -38,6 +39,7 @@ class Item extends PureComponent {
state = {
loaded: false,
+ error: false,
};
handleMouseEnter = (e) => {
@@ -81,6 +83,10 @@ class Item extends PureComponent {
this.setState({ loaded: true });
};
+ handleImageError = () => {
+ this.setState({ error: true });
+ };
+
render () {
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
@@ -113,16 +119,16 @@ class Item extends PureComponent {
width = 25;
}
- if (attachment.get('description')?.length > 0) {
- badges.push();
- }
-
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
+ if (description?.length > 0) {
+ badges.push();
+ }
+
if (attachment.get('type') === 'unknown') {
return (
-
+
);
@@ -183,7 +189,6 @@ class Item extends PureComponent {