Merge remote-tracking branch 'parent/main' into upstream-2024112
This commit is contained in:
commit
3359008684
71 changed files with 1505 additions and 2295 deletions
|
@ -1,10 +1,6 @@
|
|||
import { bookmarkCategoryNeeded } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { unbookmark } from './interactions';
|
||||
|
||||
export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS';
|
||||
|
@ -14,18 +10,6 @@ export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQU
|
|||
export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS';
|
||||
export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP';
|
||||
|
||||
export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL';
|
||||
|
@ -34,25 +18,13 @@ export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATU
|
|||
export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET';
|
||||
export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP';
|
||||
|
||||
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST';
|
||||
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL';
|
||||
|
||||
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
|
||||
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
|
||||
|
||||
export const fetchBookmarkCategory = id => (dispatch, getState) => {
|
||||
if (getState().getIn(['bookmark_categories', id])) {
|
||||
return;
|
||||
|
@ -103,89 +75,6 @@ export const fetchBookmarkCategoriesFail = error => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => {
|
||||
const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']);
|
||||
const title = getState().getIn(['bookmarkCategoryEditor', 'title']);
|
||||
|
||||
if (bookmarkCategoryId === null) {
|
||||
dispatch(createBookmarkCategory(title, shouldReset));
|
||||
} else {
|
||||
dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_SETUP,
|
||||
bookmarkCategory: getState().getIn(['bookmark_categories', bookmarkCategoryId]),
|
||||
});
|
||||
|
||||
dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId));
|
||||
};
|
||||
|
||||
export const changeBookmarkCategoryEditorTitle = value => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => {
|
||||
dispatch(createBookmarkCategoryRequest());
|
||||
|
||||
api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => {
|
||||
dispatch(createBookmarkCategorySuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetBookmarkCategoryEditor());
|
||||
}
|
||||
}).catch(err => dispatch(createBookmarkCategoryFail(err)));
|
||||
};
|
||||
|
||||
export const createBookmarkCategoryRequest = () => ({
|
||||
type: BOOKMARK_CATEGORY_CREATE_REQUEST,
|
||||
});
|
||||
|
||||
export const createBookmarkCategorySuccess = bookmarkCategory => ({
|
||||
type: BOOKMARK_CATEGORY_CREATE_SUCCESS,
|
||||
bookmarkCategory,
|
||||
});
|
||||
|
||||
export const createBookmarkCategoryFail = error => ({
|
||||
type: BOOKMARK_CATEGORY_CREATE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => {
|
||||
dispatch(updateBookmarkCategoryRequest(id));
|
||||
|
||||
api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => {
|
||||
dispatch(updateBookmarkCategorySuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetBookmarkCategoryEditor());
|
||||
}
|
||||
}).catch(err => dispatch(updateBookmarkCategoryFail(id, err)));
|
||||
};
|
||||
|
||||
export const updateBookmarkCategoryRequest = id => ({
|
||||
type: BOOKMARK_CATEGORY_UPDATE_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const updateBookmarkCategorySuccess = bookmarkCategory => ({
|
||||
type: BOOKMARK_CATEGORY_UPDATE_SUCCESS,
|
||||
bookmarkCategory,
|
||||
});
|
||||
|
||||
export const updateBookmarkCategoryFail = (id, error) => ({
|
||||
type: BOOKMARK_CATEGORY_UPDATE_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetBookmarkCategoryEditor = () => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_RESET,
|
||||
});
|
||||
|
||||
export const deleteBookmarkCategory = id => (dispatch, getState) => {
|
||||
dispatch(deleteBookmarkCategoryRequest(id));
|
||||
|
||||
|
@ -238,116 +127,6 @@ export const fetchBookmarkCategoryStatusesFail = (id, error) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
|
||||
dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId));
|
||||
|
||||
api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] })
|
||||
.then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
|
||||
.catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
|
||||
};
|
||||
|
||||
export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
|
||||
dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId));
|
||||
|
||||
api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } })
|
||||
.then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
|
||||
.catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
|
||||
};
|
||||
|
||||
export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
|
||||
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL,
|
||||
bookmarkCategoryId,
|
||||
statusId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetBookmarkCategoryAdder = () => ({
|
||||
type: BOOKMARK_CATEGORY_ADDER_RESET,
|
||||
});
|
||||
|
||||
export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: BOOKMARK_CATEGORY_ADDER_SETUP,
|
||||
status: getState().getIn(['statuses', statusId]),
|
||||
});
|
||||
dispatch(fetchBookmarkCategories());
|
||||
dispatch(fetchStatusBookmarkCategories(statusId));
|
||||
};
|
||||
|
||||
export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => {
|
||||
dispatch(fetchStatusBookmarkCategoriesRequest(statusId));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`)
|
||||
.then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data)))
|
||||
.catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err)));
|
||||
};
|
||||
|
||||
export const fetchStatusBookmarkCategoriesRequest = id => ({
|
||||
type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({
|
||||
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
|
||||
id,
|
||||
bookmarkCategories,
|
||||
});
|
||||
|
||||
export const fetchStatusBookmarkCategoriesFail = (id, err) => ({
|
||||
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
|
||||
id,
|
||||
err,
|
||||
});
|
||||
|
||||
export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
|
||||
dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
|
||||
};
|
||||
|
||||
export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
|
||||
if (bookmarkCategoryNeeded) {
|
||||
const categories = getState().getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']);
|
||||
if (categories && categories.count() <= 1) {
|
||||
const status = makeGetStatus()(getState(), { id: getState().getIn(['bookmarkCategoryAdder', 'statusId']) });
|
||||
dispatch(unbookmark(status));
|
||||
} else {
|
||||
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
|
||||
}
|
||||
} else {
|
||||
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
|
||||
}
|
||||
};
|
||||
|
||||
export function expandBookmarkCategoryStatuses(bookmarkCategoryId) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null);
|
||||
|
@ -392,3 +171,19 @@ export function expandBookmarkCategoryStatusesFail(id, error) {
|
|||
};
|
||||
}
|
||||
|
||||
export function bookmarkCategoryEditorAddSuccess(id, statusId) {
|
||||
return {
|
||||
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
id,
|
||||
statusId,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookmarkCategoryEditorRemoveSuccess(id, statusId) {
|
||||
return {
|
||||
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
|
||||
id,
|
||||
statusId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -272,14 +272,14 @@ export function submitCompose() {
|
|||
insertIfOnline('home');
|
||||
}
|
||||
|
||||
if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility_ex === 'public') {
|
||||
if (statusId === null && response.data.in_reply_to_id === null && ['public', 'public_unlisted', 'login'].includes(response.data.visibility_ex)) {
|
||||
insertIfOnline('community');
|
||||
insertIfOnline('public');
|
||||
insertIfOnline(`account:${response.data.account.id}`);
|
||||
}
|
||||
|
||||
if (statusId === null && privacy === 'circle' && circleId !== null && circleId !== 0) {
|
||||
dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId));
|
||||
dispatch(submitComposeWithCircleSuccess({ ...response.data }, `${circleId}`));
|
||||
}
|
||||
|
||||
dispatch(showAlert({
|
||||
|
@ -310,7 +310,7 @@ export function submitComposeSuccess(status) {
|
|||
export function submitComposeWithCircleSuccess(status, circleId) {
|
||||
return {
|
||||
type: COMPOSE_WITH_CIRCLE_SUCCESS,
|
||||
status,
|
||||
statusId: status.id,
|
||||
circleId,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(() => {});
|
||||
};
|
24
app/javascript/mastodon/actions/suggestions.ts
Normal file
24
app/javascript/mastodon/actions/suggestions.ts
Normal file
|
@ -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),
|
||||
);
|
|
@ -19,7 +19,7 @@ export const apiUpdate = (bookmarkCategory: Partial<ApiBookmarkCategoryJSON>) =>
|
|||
bookmarkCategory,
|
||||
);
|
||||
|
||||
export const apiGetAccounts = (bookmarkCategoryId: string) =>
|
||||
export const apiGetStatuses = (bookmarkCategoryId: string) =>
|
||||
apiRequestGet<ApiAccountJSON[]>(
|
||||
`v1/bookmark_categories/${bookmarkCategoryId}/statuses`,
|
||||
{
|
||||
|
@ -27,23 +27,23 @@ export const apiGetAccounts = (bookmarkCategoryId: string) =>
|
|||
},
|
||||
);
|
||||
|
||||
export const apiGetAccountBookmarkCategories = (accountId: string) =>
|
||||
export const apiGetStatusBookmarkCategories = (accountId: string) =>
|
||||
apiRequestGet<ApiBookmarkCategoryJSON[]>(
|
||||
`v1/statuses/${accountId}/bookmark_categories`,
|
||||
);
|
||||
|
||||
export const apiAddAccountToBookmarkCategory = (
|
||||
export const apiAddStatusToBookmarkCategory = (
|
||||
bookmarkCategoryId: string,
|
||||
accountId: string,
|
||||
statusId: string,
|
||||
) =>
|
||||
apiRequestPost(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
|
||||
account_ids: [accountId],
|
||||
status_ids: [statusId],
|
||||
});
|
||||
|
||||
export const apiRemoveAccountFromBookmarkCategory = (
|
||||
export const apiRemoveStatusFromBookmarkCategory = (
|
||||
bookmarkCategoryId: string,
|
||||
accountId: string,
|
||||
statusId: string,
|
||||
) =>
|
||||
apiRequestDelete(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
|
||||
account_ids: [accountId],
|
||||
status_ids: [statusId],
|
||||
});
|
||||
|
|
8
app/javascript/mastodon/api/suggestions.ts
Normal file
8
app/javascript/mastodon/api/suggestions.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
|
||||
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
|
||||
|
||||
export const apiGetSuggestions = (limit: number) =>
|
||||
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
|
||||
|
||||
export const apiDeleteSuggestion = (accountId: string) =>
|
||||
apiRequestDelete(`v1/suggestions/${accountId}`);
|
13
app/javascript/mastodon/api_types/suggestions.ts
Normal file
13
app/javascript/mastodon/api_types/suggestions.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -10,6 +10,7 @@ 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 { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
|
||||
|
@ -23,9 +24,6 @@ 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' },
|
||||
|
@ -35,13 +33,9 @@ const messages = defineMessages({
|
|||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, hideButtons, minimal, defaultAction, children, withBio }) => {
|
||||
const Account = ({ size = 46, account, 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]);
|
||||
|
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
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 = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||
} else if (muting) {
|
||||
|
@ -109,9 +102,11 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
@ -179,7 +174,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
Account.propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.record,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
|
|
|
@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
|
|||
}, [history, onClick]);
|
||||
}
|
||||
|
||||
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
|
||||
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = useHandleClick(onClick);
|
||||
|
|
67
app/javascript/mastodon/components/column_search_header.tsx
Normal file
67
app/javascript/mastodon/components/column_search_header.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const ColumnSearchHeader: React.FC<{
|
||||
onBack: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onActivate: () => void;
|
||||
placeholder: string;
|
||||
active: boolean;
|
||||
}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setValue('');
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
onSubmit(value);
|
||||
},
|
||||
[setValue, onSubmit],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[onBack],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onActivate();
|
||||
}, [onActivate]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit(value);
|
||||
}, [onSubmit, value]);
|
||||
|
||||
return (
|
||||
<form className='column-search-header' onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='search'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder={placeholder}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
|
||||
{active && (
|
||||
<button type='button' className='link-button' onClick={onBack}>
|
||||
<FormattedMessage id='column_search.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -103,7 +103,12 @@ export const FollowButton: React.FC<{
|
|||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||
disabled={
|
||||
relationship?.blocked_by ||
|
||||
relationship?.blocking ||
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
secondary={following}
|
||||
className={following ? 'button--destructive' : undefined}
|
||||
>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
|
||||
|
@ -144,6 +145,20 @@ const BookmarkCategories: React.FC<{
|
|||
scrollKey='bookmark_categories'
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
alwaysPrepend
|
||||
prepend={
|
||||
<div className='lists__item'>
|
||||
<Link to={'/bookmarks'} className='lists__item__title'>
|
||||
<Icon id='bookmarks' icon={BookmarksIcon} />
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='bookmark_categories.all_bookmarks'
|
||||
defaultMessage='All bookmarks'
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{bookmark_categories.map((bookmark_category) => (
|
||||
<BookmarkCategoryItem
|
||||
|
|
|
@ -6,18 +6,24 @@ import { isFulfilled } from '@reduxjs/toolkit';
|
|||
|
||||
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
|
||||
import { createBookmarkCategory } from 'mastodon/actions/bookmark_categories_typed';
|
||||
import {
|
||||
apiGetAccountBookmarkCategories,
|
||||
apiAddAccountToBookmarkCategory,
|
||||
apiRemoveAccountFromBookmarkCategory,
|
||||
bookmarkCategoryEditorAddSuccess,
|
||||
bookmarkCategoryEditorRemoveSuccess,
|
||||
fetchBookmarkCategories,
|
||||
} from 'mastodon/actions/bookmark_categories';
|
||||
import { createBookmarkCategory } from 'mastodon/actions/bookmark_categories_typed';
|
||||
import { unbookmark } from 'mastodon/actions/interactions';
|
||||
import {
|
||||
apiGetStatusBookmarkCategories,
|
||||
apiAddStatusToBookmarkCategory,
|
||||
apiRemoveStatusFromBookmarkCategory,
|
||||
} from 'mastodon/api/bookmark_categories';
|
||||
import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { bookmarkCategoryNeeded } from 'mastodon/initial_state';
|
||||
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
|
@ -123,6 +129,8 @@ const BookmarkCategoryAdder: React.FC<{
|
|||
const bookmark_categories = useAppSelector((state) =>
|
||||
getOrderedBookmarkCategories(state),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
const [bookmark_categoryIds, setBookmarkCategoryIds] = useState<string[]>(
|
||||
[] as string[],
|
||||
);
|
||||
|
@ -130,7 +138,7 @@ const BookmarkCategoryAdder: React.FC<{
|
|||
useEffect(() => {
|
||||
dispatch(fetchBookmarkCategories());
|
||||
|
||||
apiGetAccountBookmarkCategories(statusId)
|
||||
apiGetStatusBookmarkCategories(statusId)
|
||||
.then((data) => {
|
||||
setBookmarkCategoryIds(data.map((l) => l.id));
|
||||
return '';
|
||||
|
@ -148,32 +156,53 @@ const BookmarkCategoryAdder: React.FC<{
|
|||
...currentBookmarkCategoryIds,
|
||||
]);
|
||||
|
||||
apiAddAccountToBookmarkCategory(bookmark_categoryId, statusId).catch(
|
||||
() => {
|
||||
apiAddStatusToBookmarkCategory(bookmark_categoryId, statusId)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
bookmarkCategoryEditorAddSuccess(bookmark_categoryId, statusId),
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
|
||||
currentBookmarkCategoryIds.filter(
|
||||
(id) => id !== bookmark_categoryId,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
|
||||
currentBookmarkCategoryIds.filter((id) => id !== bookmark_categoryId),
|
||||
);
|
||||
|
||||
apiRemoveAccountFromBookmarkCategory(
|
||||
bookmark_categoryId,
|
||||
statusId,
|
||||
).catch(() => {
|
||||
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
|
||||
bookmark_categoryId,
|
||||
...currentBookmarkCategoryIds,
|
||||
]);
|
||||
});
|
||||
apiRemoveStatusFromBookmarkCategory(bookmark_categoryId, statusId)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
bookmarkCategoryEditorRemoveSuccess(
|
||||
bookmark_categoryId,
|
||||
statusId,
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
bookmarkCategoryNeeded &&
|
||||
bookmark_categoryIds.filter((id) => id !== bookmark_categoryId)
|
||||
.length === 0
|
||||
) {
|
||||
dispatch(unbookmark(status));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
|
||||
bookmark_categoryId,
|
||||
...currentBookmarkCategoryIds,
|
||||
]);
|
||||
});
|
||||
}
|
||||
},
|
||||
[setBookmarkCategoryIds, statusId],
|
||||
[setBookmarkCategoryIds, statusId, dispatch, bookmark_categoryIds, status],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
|
@ -183,7 +212,7 @@ const BookmarkCategoryAdder: React.FC<{
|
|||
...currentBookmarkCategoryIds,
|
||||
]);
|
||||
|
||||
apiAddAccountToBookmarkCategory(bookmark_category.id, statusId).catch(
|
||||
apiAddStatusToBookmarkCategory(bookmark_category.id, statusId).catch(
|
||||
() => {
|
||||
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
|
||||
currentBookmarkCategoryIds.filter(
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
|
||||
import { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from '../../../actions/bookmark_categories';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'bookmark_categories.edit.submit', defaultMessage: 'Change title' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['bookmarkCategoryEditor', 'title']),
|
||||
disabled: !state.getIn(['bookmarkCategoryEditor', 'isChanged']) || !state.getIn(['bookmarkCategoryEditor', 'title']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
|
||||
onSubmit: () => dispatch(submitBookmarkCategoryEditor(false)),
|
||||
});
|
||||
|
||||
class EditBookmarkCategoryForm extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, disabled, intl } = this.props;
|
||||
|
||||
const title = intl.formatMessage(messages.title);
|
||||
|
||||
return (
|
||||
<form className='column-inline-form' onSubmit={this.handleSubmit}>
|
||||
<input
|
||||
className='setting-text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
icon='check'
|
||||
iconComponent={CheckIcon}
|
||||
title={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(EditBookmarkCategoryForm));
|
|
@ -16,7 +16,7 @@ import { debounce } from 'lodash';
|
|||
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses , setupBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
|
||||
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses } from 'mastodon/actions/bookmark_categories';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import Column from 'mastodon/components/column';
|
||||
|
@ -28,8 +28,6 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err
|
|||
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import EditBookmarkCategoryForm from './components/edit_bookmark_category_form';
|
||||
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteMessage: { id: 'confirmations.delete_bookmark_category.message', defaultMessage: 'Are you sure you want to permanently delete this category?' },
|
||||
|
@ -40,9 +38,8 @@ const messages = defineMessages({
|
|||
const mapStateToProps = (state, { params }) => ({
|
||||
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
|
||||
statusIds: getBookmarkCategoryStatusList(state, params.id),
|
||||
isLoading: state.getIn(['bookmark_categories', params.id, 'isLoading'], true),
|
||||
isEditing: state.getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']) === params.id,
|
||||
hasMore: !!state.getIn(['bookmark_categories', params.id, 'next']),
|
||||
isLoading: state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'next']),
|
||||
});
|
||||
|
||||
class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
||||
|
@ -57,7 +54,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isEditing: PropTypes.bool,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
|
@ -66,6 +62,16 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = nextProps.params;
|
||||
|
||||
if (id !== this.props.params.id) {
|
||||
dispatch(fetchBookmarkCategory(id));
|
||||
dispatch(fetchBookmarkCategoryStatuses(id));
|
||||
}
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
|
@ -87,7 +93,7 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleEditClick = () => {
|
||||
this.props.dispatch(setupBookmarkCategoryEditor(this.props.params.id));
|
||||
this.props.history.push(`/bookmark_categories/${this.props.params.id}/edit`);
|
||||
};
|
||||
|
||||
handleDeleteClick = () => {
|
||||
|
@ -121,7 +127,7 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading, isEditing } = this.props;
|
||||
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
if (typeof bookmarkCategory === 'undefined') {
|
||||
|
@ -140,10 +146,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
|
||||
|
||||
const editor = isEditing && (
|
||||
<EditBookmarkCategoryForm />
|
||||
);
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
|
@ -165,8 +167,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
|
|||
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
|
||||
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='bookmark_categories.delete' defaultMessage='Delete category' />
|
||||
</button>
|
||||
|
||||
{editor}
|
||||
</section>
|
||||
</div>
|
||||
</ColumnHeader>
|
||||
|
|
|
@ -38,9 +38,8 @@ const messages = defineMessages({
|
|||
const mapStateToProps = (state, { params }) => ({
|
||||
circle: state.getIn(['circles', params.id]),
|
||||
statusIds: getCircleStatusList(state, params.id),
|
||||
isLoading: state.getIn(['circles', params.id, 'isLoading'], true),
|
||||
isEditing: state.getIn(['circleEditor', 'circleId']) === params.id,
|
||||
hasMore: !!state.getIn(['circles', params.id, 'next']),
|
||||
isLoading: state.getIn(['status_lists', 'circle_statuses', params.id, 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'circle_statuses', params.id, 'next']),
|
||||
});
|
||||
|
||||
class CircleStatuses extends ImmutablePureComponent {
|
||||
|
@ -63,6 +62,16 @@ class CircleStatuses extends ImmutablePureComponent {
|
|||
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = nextProps.params;
|
||||
|
||||
if (id !== this.props.params.id) {
|
||||
dispatch(fetchCircle(id));
|
||||
dispatch(fetchCircleStatuses(id));
|
||||
}
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||
|
@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|||
import { Card } from './components/card';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
suggestions: state.getIn(['suggestions', 'items']),
|
||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
||||
suggestions: state.suggestions.items,
|
||||
isLoading: state.suggestions.isLoading,
|
||||
});
|
||||
|
||||
class Suggestions extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
suggestions: PropTypes.array,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
@ -32,17 +31,17 @@ class Suggestions extends PureComponent {
|
|||
const { dispatch, suggestions, history } = this.props;
|
||||
|
||||
// If we're navigating back to the screen, do not trigger a reload
|
||||
if (history.action === 'POP' && suggestions.size > 0) {
|
||||
if (history.action === 'POP' && suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSuggestions(true));
|
||||
dispatch(fetchSuggestions());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { isLoading, suggestions } = this.props;
|
||||
|
||||
if (!isLoading && suggestions.isEmpty()) {
|
||||
if (!isLoading && suggestions.length === 0) {
|
||||
return (
|
||||
<div className='explore__suggestions scrollable scrollable--flex'>
|
||||
<div className='empty-column-indicator'>
|
||||
|
@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
|
|||
<div className='explore__suggestions scrollable' data-nosnippet>
|
||||
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
||||
<Card
|
||||
key={suggestion.get('account')}
|
||||
id={suggestion.get('account')}
|
||||
source={suggestion.getIn(['sources', 0])}
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
source={suggestion.sources[0]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,217 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
|
||||
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
|
||||
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
|
||||
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
|
||||
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
|
||||
});
|
||||
|
||||
const Source = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
||||
switch (id) {
|
||||
case 'friends_of_friends':
|
||||
hint = intl.formatMessage(messages.friendsOfFriendsHint);
|
||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
|
||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
||||
break;
|
||||
case 'featured':
|
||||
hint = intl.formatMessage(messages.featuredHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
|
||||
break;
|
||||
case 'most_followed':
|
||||
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
||||
break;
|
||||
case 'most_interactions':
|
||||
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
|
||||
<Icon icon={InfoIcon} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Source.propTypes = {
|
||||
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
|
||||
};
|
||||
|
||||
const Card = ({ id, sources }) => {
|
||||
const intl = useIntl();
|
||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissSuggestion(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card'>
|
||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
|
||||
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
|
||||
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
sources: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions = ({ hidden }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
|
||||
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
|
||||
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
|
||||
const bodyRef = useRef();
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
bodyRef.current.scrollLeft -= 200;
|
||||
}, [bodyRef]);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
bodyRef.current.scrollLeft += 200;
|
||||
}, [bodyRef]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
||||
}, [dispatch]);
|
||||
|
||||
if (dismissed || (!isLoading && suggestions.isEmpty())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div className='inline-follow-suggestions' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
|
||||
|
||||
<div className='inline-follow-suggestions__header__actions'>
|
||||
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
|
||||
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body'>
|
||||
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
|
||||
{suggestions.map(suggestion => (
|
||||
<Card
|
||||
key={suggestion.get('account')}
|
||||
id={suggestion.get('account')}
|
||||
sources={suggestion.get('sources')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canScrollRight && (
|
||||
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineFollowSuggestions.propTypes = {
|
||||
hidden: PropTypes.bool,
|
||||
};
|
|
@ -0,0 +1,326 @@
|
|||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import {
|
||||
fetchSuggestions,
|
||||
dismissSuggestion,
|
||||
} from 'mastodon/actions/suggestions';
|
||||
import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
dismiss: {
|
||||
id: 'follow_suggestions.dismiss',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
friendsOfFriendsHint: {
|
||||
id: 'follow_suggestions.hints.friends_of_friends',
|
||||
defaultMessage: 'This profile is popular among the people you follow.',
|
||||
},
|
||||
similarToRecentlyFollowedHint: {
|
||||
id: 'follow_suggestions.hints.similar_to_recently_followed',
|
||||
defaultMessage:
|
||||
'This profile is similar to the profiles you have most recently followed.',
|
||||
},
|
||||
featuredHint: {
|
||||
id: 'follow_suggestions.hints.featured',
|
||||
defaultMessage: 'This profile has been hand-picked by the {domain} team.',
|
||||
},
|
||||
mostFollowedHint: {
|
||||
id: 'follow_suggestions.hints.most_followed',
|
||||
defaultMessage: 'This profile is one of the most followed on {domain}.',
|
||||
},
|
||||
mostInteractionsHint: {
|
||||
id: 'follow_suggestions.hints.most_interactions',
|
||||
defaultMessage:
|
||||
'This profile has been recently getting a lot of attention on {domain}.',
|
||||
},
|
||||
});
|
||||
|
||||
const Source: React.FC<{
|
||||
id: ApiSuggestionSourceJSON;
|
||||
}> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
||||
switch (id) {
|
||||
case 'friends_of_friends':
|
||||
hint = intl.formatMessage(messages.friendsOfFriendsHint);
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.personalized_suggestion'
|
||||
defaultMessage='Personalized suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.personalized_suggestion'
|
||||
defaultMessage='Personalized suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'featured':
|
||||
hint = intl.formatMessage(messages.featuredHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.curated_suggestion'
|
||||
defaultMessage='Staff pick'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_followed':
|
||||
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion'
|
||||
defaultMessage='Popular suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_interactions':
|
||||
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion'
|
||||
defaultMessage='Popular suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inline-follow-suggestions__body__scrollable__card__text-stack__source'
|
||||
title={hint}
|
||||
>
|
||||
<Icon id='' icon={InfoIcon} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Card: React.FC<{
|
||||
id: string;
|
||||
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
|
||||
}> = ({ id, sources }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card'>
|
||||
<IconButton
|
||||
icon=''
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleDismiss}
|
||||
title={intl.formatMessage(messages.dismiss)}
|
||||
/>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
|
||||
<Link to={`/@${account?.acct}`}>
|
||||
<Avatar account={account} size={72} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
|
||||
<Link to={`/@${account?.acct}`}>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
{firstVerifiedField ? (
|
||||
<VerifiedBadge link={firstVerifiedField.value} />
|
||||
) : (
|
||||
<Source id={sources[0]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions: React.FC<{
|
||||
hidden?: boolean;
|
||||
}> = ({ hidden }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
const dismissed = useAppSelector(
|
||||
(state) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
|
||||
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
|
||||
);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft -= 200;
|
||||
}, []);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft += 200;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
||||
}, [dispatch]);
|
||||
|
||||
if (dismissed || (!isLoading && suggestions.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return <div className='inline-follow-suggestions' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.who_to_follow'
|
||||
defaultMessage='Who to follow'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='inline-follow-suggestions__header__actions'>
|
||||
<button className='link-button' onClick={handleDismiss}>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.dismiss'
|
||||
defaultMessage="Don't show again"
|
||||
/>
|
||||
</button>
|
||||
<Link to='/explore/suggestions' className='link-button'>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.view_all'
|
||||
defaultMessage='View all'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body'>
|
||||
<div
|
||||
className='inline-follow-suggestions__body__scrollable'
|
||||
ref={bodyRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
className='inline-follow-suggestions__body__scroll-button left'
|
||||
onClick={handleLeftNav}
|
||||
aria-label={intl.formatMessage(messages.previous)}
|
||||
>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'>
|
||||
<Icon id='' icon={ChevronLeftIcon} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canScrollRight && (
|
||||
<button
|
||||
className='inline-follow-suggestions__body__scroll-button right'
|
||||
onClick={handleRightNav}
|
||||
aria-label={intl.formatMessage(messages.next)}
|
||||
>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'>
|
||||
<Icon id='' icon={ChevronRightIcon} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -7,11 +7,8 @@ import { useParams, Link } from 'react-router-dom';
|
|||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { fetchFollowing } from 'mastodon/actions/accounts';
|
||||
import { importFetchedAccounts } from 'mastodon/actions/importer';
|
||||
import { fetchList } from 'mastodon/actions/lists';
|
||||
import { apiRequest } from 'mastodon/api';
|
||||
|
@ -25,14 +22,12 @@ import { Avatar } from 'mastodon/components/avatar';
|
|||
import { Button } from 'mastodon/components/button';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -49,54 +44,6 @@ const messages = defineMessages({
|
|||
|
||||
type Mode = 'remove' | 'add';
|
||||
|
||||
const ColumnSearchHeader: React.FC<{
|
||||
onBack: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
}> = ({ onBack, onSubmit }) => {
|
||||
const intl = useIntl();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
onSubmit(value);
|
||||
},
|
||||
[setValue, onSubmit],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit(value);
|
||||
}, [onSubmit, value]);
|
||||
|
||||
return (
|
||||
<ButtonInTabsBar>
|
||||
<form className='column-search-header' onSubmit={handleSubmit}>
|
||||
<button
|
||||
type='button'
|
||||
className='column-header__back-button compact'
|
||||
onClick={onBack}
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
>
|
||||
<Icon
|
||||
id='chevron-left'
|
||||
icon={ArrowBackIcon}
|
||||
className='column-back-button__icon'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type='search'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
</ButtonInTabsBar>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountItem: React.FC<{
|
||||
accountId: string;
|
||||
listId: string;
|
||||
|
@ -156,6 +103,7 @@ const AccountItem: React.FC<{
|
|||
text={intl.formatMessage(
|
||||
partOfList ? messages.remove : messages.add,
|
||||
)}
|
||||
secondary={partOfList}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
|
@ -171,9 +119,6 @@ const ListMembers: React.FC<{
|
|||
const { id } = useParams<{ id: string }>();
|
||||
const intl = useIntl();
|
||||
|
||||
const followingAccountIds = useAppSelector(
|
||||
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
|
||||
);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||
|
@ -195,8 +140,6 @@ const ListMembers: React.FC<{
|
|||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
dispatch(fetchFollowing(me));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
|
@ -265,8 +208,8 @@ const ListMembers: React.FC<{
|
|||
|
||||
let displayedAccountIds: string[];
|
||||
|
||||
if (mode === 'add') {
|
||||
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
|
||||
if (mode === 'add' && searching) {
|
||||
displayedAccountIds = searchAccountIds;
|
||||
} else {
|
||||
displayedAccountIds = accountIds;
|
||||
}
|
||||
|
@ -276,31 +219,21 @@ const ListMembers: React.FC<{
|
|||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
{mode === 'remove' ? (
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
extraButton={
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
type='button'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.enterSearch)}
|
||||
aria-label={intl.formatMessage(messages.enterSearch)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ColumnSearchHeader
|
||||
onBack={handleDismissSearchClick}
|
||||
onSubmit={handleSearch}
|
||||
/>
|
||||
)}
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ColumnSearchHeader
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
onBack={handleDismissSearchClick}
|
||||
onSubmit={handleSearch}
|
||||
onActivate={handleSearchClick}
|
||||
active={mode === 'add'}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='list_members'
|
||||
|
@ -310,17 +243,15 @@ const ListMembers: React.FC<{
|
|||
showLoading={loading && displayedAccountIds.length === 0}
|
||||
hasMore={false}
|
||||
footer={
|
||||
mode === 'remove' && (
|
||||
<>
|
||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||
<>
|
||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||
|
||||
<div className='column-footer'>
|
||||
<Link to={`/lists/${id}`} className='button button--block'>
|
||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
<div className='column-footer'>
|
||||
<Link to={`/lists/${id}`} className='button button--block'>
|
||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
emptyMessage={
|
||||
mode === 'remove' ? (
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import ReferenceIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { enableEmojiReaction } from 'mastodon/initial_state';
|
||||
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
|
||||
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
status_references: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||
});
|
||||
|
||||
class FilterBar extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
selectFilter: PropTypes.func.isRequired,
|
||||
selectedFilter: PropTypes.string.isRequired,
|
||||
advancedMode: PropTypes.bool.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onClick (notificationType) {
|
||||
return () => this.props.selectFilter(notificationType);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { selectedFilter, advancedMode, intl } = this.props;
|
||||
const renderedElement = !advancedMode ? (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.mentions'
|
||||
defaultMessage='Mentions'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
title={intl.formatMessage(tooltips.mentions)}
|
||||
>
|
||||
<Icon id='reply-all' icon={ReplyAllIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'favourite' ? 'active' : ''}
|
||||
onClick={this.onClick('favourite')}
|
||||
title={intl.formatMessage(tooltips.favourites)}
|
||||
>
|
||||
<Icon id='star' icon={StarIcon} />
|
||||
</button>
|
||||
{enableEmojiReaction && (
|
||||
<button
|
||||
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('emoji_reaction')}
|
||||
title={intl.formatMessage(tooltips.emojiReactions)}
|
||||
>
|
||||
<Icon id='smile-o' icon={EmojiReactionIcon} fixedWidth />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
title={intl.formatMessage(tooltips.boosts)}
|
||||
>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'status_reference' ? 'active' : ''}
|
||||
onClick={this.onClick('status_reference')}
|
||||
title={intl.formatMessage(tooltips.status_references)}
|
||||
>
|
||||
<Icon id='link' icon={ReferenceIcon} fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'poll' ? 'active' : ''}
|
||||
onClick={this.onClick('poll')}
|
||||
title={intl.formatMessage(tooltips.polls)}
|
||||
>
|
||||
<Icon id='tasks' icon={InsertChartIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'status' ? 'active' : ''}
|
||||
onClick={this.onClick('status')}
|
||||
title={intl.formatMessage(tooltips.statuses)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'follow' ? 'active' : ''}
|
||||
onClick={this.onClick('follow')}
|
||||
title={intl.formatMessage(tooltips.follows)}
|
||||
>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return renderedElement;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(FilterBar);
|
|
@ -1,17 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { setFilter } from '../../../actions/notifications';
|
||||
import FilterBar from '../components/filter_bar';
|
||||
|
||||
const makeMapStateToProps = state => ({
|
||||
selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
|
||||
advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
selectFilter (newActiveFilter) {
|
||||
dispatch(setFilter(newActiveFilter));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
|
|
@ -1,308 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { submitMarkers } from '../../actions/markers';
|
||||
import {
|
||||
expandNotifications,
|
||||
scrollTopNotifications,
|
||||
loadPending,
|
||||
mountNotifications,
|
||||
unmountNotifications,
|
||||
markNotificationsAsRead,
|
||||
} from '../../actions/notifications';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { LoadGap } from '../../components/load_gap';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
|
||||
import {
|
||||
FilteredNotificationsBanner,
|
||||
FilteredNotificationsIconButton,
|
||||
} from './components/filtered_notifications_banner';
|
||||
import NotificationsPermissionBanner from './components/notifications_permission_banner';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import FilterBarContainer from './containers/filter_bar_container';
|
||||
import NotificationContainer from './containers/notification_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
|
||||
});
|
||||
|
||||
const getExcludedTypes = createSelector([
|
||||
state => state.getIn(['settings', 'notifications', 'shows']),
|
||||
], (shows) => {
|
||||
return ImmutableList(shows.filter(item => !item).keys());
|
||||
});
|
||||
|
||||
const getNotifications = createSelector([
|
||||
state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||
state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
|
||||
getExcludedTypes,
|
||||
state => state.getIn(['notifications', 'items']),
|
||||
], (showFilterBar, allowedType, excludedTypes, notifications) => {
|
||||
if (!showFilterBar || allowedType === 'all') {
|
||||
// used if user changed the notification settings after loading the notifications from the server
|
||||
// otherwise a list of notifications will come pre-filtered from the backend
|
||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
||||
}
|
||||
return notifications.filter(item => item === null || allowedType === item.get('type'));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
notifications: getNotifications(state),
|
||||
isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
|
||||
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
||||
lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
|
||||
canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
|
||||
needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
|
||||
});
|
||||
|
||||
class Notifications extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
columnId: PropTypes.string,
|
||||
notifications: ImmutablePropTypes.list.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
isUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
numPending: PropTypes.number,
|
||||
lastReadId: PropTypes.string,
|
||||
canMarkAsRead: PropTypes.bool,
|
||||
needsNotificationPermission: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
this.props.dispatch(mountNotifications());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.handleLoadOlder.cancel();
|
||||
this.handleScrollToTop.cancel();
|
||||
this.handleScroll.cancel();
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
this.props.dispatch(unmountNotifications());
|
||||
}
|
||||
|
||||
handleLoadGap = (maxId) => {
|
||||
this.props.dispatch(expandNotifications({ maxId }));
|
||||
};
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const last = this.props.notifications.last();
|
||||
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
||||
}, 300, { leading: true });
|
||||
|
||||
handleLoadPending = () => {
|
||||
this.props.dispatch(loadPending());
|
||||
};
|
||||
|
||||
handleScrollToTop = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(true));
|
||||
}, 100);
|
||||
|
||||
handleScroll = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
}, 100);
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('NOTIFICATIONS', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
this._selectChild(elementIndex, true);
|
||||
};
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
this._selectChild(elementIndex, false);
|
||||
};
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.column.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleMarkAsRead = () => {
|
||||
this.props.dispatch(markNotificationsAsRead());
|
||||
this.props.dispatch(submitMarkers({ immediate: true }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
||||
const pinned = !!columnId;
|
||||
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
let scrollableContent = null;
|
||||
|
||||
const filterBarContainer = signedIn
|
||||
? (<FilterBarContainer />)
|
||||
: null;
|
||||
|
||||
if (isLoading && this.scrollableContent) {
|
||||
scrollableContent = this.scrollableContent;
|
||||
} else if (notifications.size > 0 || hasMore) {
|
||||
scrollableContent = notifications.map((item, index) => item === null ? (
|
||||
<LoadGap
|
||||
key={'gap:' + notifications.getIn([index + 1, 'id'])}
|
||||
disabled={isLoading}
|
||||
param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
|
||||
onClick={this.handleLoadGap}
|
||||
/>
|
||||
) : (
|
||||
<NotificationContainer
|
||||
key={item.get('id')}
|
||||
notification={item}
|
||||
accountId={item.get('account')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
scrollableContent = null;
|
||||
}
|
||||
|
||||
this.scrollableContent = scrollableContent;
|
||||
|
||||
let scrollContainer;
|
||||
|
||||
const prepend = (
|
||||
<>
|
||||
{needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||
<FilteredNotificationsBanner />
|
||||
</>
|
||||
);
|
||||
|
||||
if (signedIn) {
|
||||
scrollContainer = (
|
||||
<ScrollableList
|
||||
scrollKey={`notifications-${columnId}`}
|
||||
trackScroll={!pinned}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
numPending={numPending}
|
||||
prepend={prepend}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
onLoadMore={this.handleLoadOlder}
|
||||
onLoadPending={this.handleLoadPending}
|
||||
onScrollToTop={this.handleScrollToTop}
|
||||
onScroll={this.handleScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
} else {
|
||||
scrollContainer = <NotSignedInIndicator />;
|
||||
}
|
||||
|
||||
const extraButton = (
|
||||
<>
|
||||
<FilteredNotificationsIconButton className='column-header__button' />
|
||||
{canMarkAsRead && (
|
||||
<button
|
||||
aria-label={intl.formatMessage(messages.markAsRead)}
|
||||
title={intl.formatMessage(messages.markAsRead)}
|
||||
onClick={this.handleMarkAsRead}
|
||||
className='column-header__button'
|
||||
>
|
||||
<Icon id='done-all' icon={DoneAllIcon} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='bell'
|
||||
iconComponent={NotificationsIcon}
|
||||
active={isUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={extraButton}
|
||||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
{filterBarContainer}
|
||||
|
||||
{scrollContainer}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(withIdentity(injectIntl(Notifications)));
|
|
@ -1,9 +0,0 @@
|
|||
import Notifications_v2 from 'mastodon/features/notifications_v2';
|
||||
|
||||
export const NotificationsWrapper = (props) => {
|
||||
return (
|
||||
<Notifications_v2 {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWrapper;
|
|
@ -1,57 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import CheckIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => {
|
||||
const content = (
|
||||
<>
|
||||
<div className='onboarding__steps__item__icon'>
|
||||
<Icon id={icon} icon={iconComponent} />
|
||||
</div>
|
||||
|
||||
<div className='onboarding__steps__item__description'>
|
||||
<h6>{label}</h6>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
|
||||
{completed ? <Icon icon={CheckIcon} /> : <Icon icon={ArrowRightAltIcon} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
} else if (to) {
|
||||
return (
|
||||
<Link to={to} className='onboarding__steps__item'>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className='onboarding__steps__item'>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Step.propTypes = {
|
||||
label: PropTypes.node,
|
||||
description: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
iconComponent: PropTypes.func,
|
||||
completed: PropTypes.bool,
|
||||
href: PropTypes.string,
|
||||
to: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
|
@ -1,62 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||
import { markAsPartial } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||
import Account from 'mastodon/containers/account_container';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const Follows = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading']));
|
||||
const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items']));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSuggestions(true));
|
||||
|
||||
return () => {
|
||||
dispatch(markAsPartial('home'));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
let loadedContent;
|
||||
|
||||
if (isLoading) {
|
||||
loadedContent = (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />);
|
||||
} else if (suggestions.isEmpty()) {
|
||||
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
|
||||
} else {
|
||||
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.follows.title' defaultMessage='Popular on Mastodon' /></h3>
|
||||
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
|
||||
</div>
|
||||
|
||||
<div className='follow-recommendations'>
|
||||
{loadedContent}
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
191
app/javascript/mastodon/features/onboarding/follows.tsx
Normal file
191
app/javascript/mastodon/features/onboarding/follows.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
import { importFetchedAccounts } from 'mastodon/actions/importer';
|
||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||
import { markAsPartial } from 'mastodon/actions/timelines';
|
||||
import { apiRequest } from 'mastodon/api';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Account from 'mastodon/containers/account_container';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'onboarding.follows.title',
|
||||
defaultMessage: 'Follow people to get started',
|
||||
},
|
||||
search: { id: 'onboarding.follows.search', defaultMessage: 'Search' },
|
||||
back: { id: 'onboarding.follows.back', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
type Mode = 'remove' | 'add';
|
||||
|
||||
export const Follows: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<Mode>('remove');
|
||||
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchSuggestions());
|
||||
|
||||
return () => {
|
||||
dispatch(markAsPartial('home'));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
setMode('add');
|
||||
}, [setMode]);
|
||||
|
||||
const handleDismissSearchClick = useCallback(() => {
|
||||
setMode('remove');
|
||||
setIsSearching(false);
|
||||
}, [setMode, setIsSearching]);
|
||||
|
||||
const searchRequestRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
if (searchRequestRef.current) {
|
||||
searchRequestRef.current.abort();
|
||||
}
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
setIsSearching(false);
|
||||
setSearchAccountIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setIsLoadingSearch(true);
|
||||
|
||||
searchRequestRef.current = new AbortController();
|
||||
|
||||
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
|
||||
signal: searchRequestRef.current.signal,
|
||||
params: {
|
||||
q: value,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((a) => a.id)));
|
||||
setSearchAccountIds(data.map((a) => a.id));
|
||||
setIsLoadingSearch(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoadingSearch(false);
|
||||
});
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
let displayedAccountIds: string[];
|
||||
|
||||
if (mode === 'add' && isSearching) {
|
||||
displayedAccountIds = searchAccountIds;
|
||||
} else {
|
||||
displayedAccountIds = suggestions.map(
|
||||
(suggestion) => suggestion.account_id,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.title)}
|
||||
icon='person'
|
||||
iconComponent={PersonIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ColumnSearchHeader
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
onBack={handleDismissSearchClick}
|
||||
onActivate={handleSearchClick}
|
||||
active={mode === 'add'}
|
||||
onSubmit={handleSearch}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='follow_recommendations'
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
showLoading={
|
||||
(isLoading || isLoadingSearch) && displayedAccountIds.length === 0
|
||||
}
|
||||
hasMore={false}
|
||||
isLoading={isLoading || isLoadingSearch}
|
||||
footer={
|
||||
<>
|
||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||
|
||||
<div className='column-footer'>
|
||||
<Link className='button button--block' to='/home'>
|
||||
<FormattedMessage
|
||||
id='onboarding.follows.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
emptyMessage={
|
||||
mode === 'remove' ? (
|
||||
<FormattedMessage
|
||||
id='onboarding.follows.empty'
|
||||
defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='lists.no_results_found'
|
||||
defaultMessage='No results found.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{displayedAccountIds.map((accountId) => (
|
||||
<Account
|
||||
/* @ts-expect-error inferred props are wrong */
|
||||
id={accountId}
|
||||
key={accountId}
|
||||
withBio={false}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Follows;
|
|
@ -1,98 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import illustration from '@/images/elephant_ui_conversation.svg';
|
||||
import AccountCircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import { focusCompose } from 'mastodon/actions/compose';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { enableLocalTimeline, me } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { Step } from './components/step';
|
||||
import { Follows } from './follows';
|
||||
import { Profile } from './profile';
|
||||
import { Share } from './share';
|
||||
|
||||
const messages = defineMessages({
|
||||
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
|
||||
});
|
||||
|
||||
const Onboarding = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleComposeClick = useCallback(() => {
|
||||
dispatch(focusCompose(intl.formatMessage(messages.template)));
|
||||
}, [dispatch, intl]);
|
||||
|
||||
return (
|
||||
<Column>
|
||||
{account ? (
|
||||
<Switch>
|
||||
<Route path='/start' exact>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<img src={illustration} alt='' className='onboarding__illustration' />
|
||||
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
|
||||
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__steps'>
|
||||
<Step to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||
<Step to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
{enableLocalTimeline && (
|
||||
<Link to='/public/local/fixed' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_local_timeline' defaultMessage='See posts from local' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path='/start/profile' component={Profile} />
|
||||
<Route path='/start/follows' component={Follows} />
|
||||
<Route path='/start/share' component={Share} />
|
||||
</Switch>
|
||||
) : <NotSignedInIndicator />}
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
|
@ -1,162 +0,0 @@
|
|||
import { useState, useMemo, useCallback, createRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import { updateAccount } from 'mastodon/actions/accounts';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import { unescapeHTML } from 'mastodon/utils/html';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' },
|
||||
uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' },
|
||||
});
|
||||
|
||||
const nullIfMissing = path => path.endsWith('missing.png') ? null : path;
|
||||
|
||||
export const Profile = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const [displayName, setDisplayName] = useState(account.get('display_name'));
|
||||
const [note, setNote] = useState(unescapeHTML(account.get('note')));
|
||||
const [avatar, setAvatar] = useState(null);
|
||||
const [header, setHeader] = useState(null);
|
||||
const [discoverable, setDiscoverable] = useState(account.get('discoverable'));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState();
|
||||
const avatarFileRef = createRef();
|
||||
const headerFileRef = createRef();
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleDisplayNameChange = useCallback(e => {
|
||||
setDisplayName(e.target.value);
|
||||
}, [setDisplayName]);
|
||||
|
||||
const handleNoteChange = useCallback(e => {
|
||||
setNote(e.target.value);
|
||||
}, [setNote]);
|
||||
|
||||
const handleDiscoverableChange = useCallback(e => {
|
||||
setDiscoverable(e.target.checked);
|
||||
}, [setDiscoverable]);
|
||||
|
||||
const handleAvatarChange = useCallback(e => {
|
||||
setAvatar(e.target?.files?.[0]);
|
||||
}, [setAvatar]);
|
||||
|
||||
const handleHeaderChange = useCallback(e => {
|
||||
setHeader(e.target?.files?.[0]);
|
||||
}, [setHeader]);
|
||||
|
||||
const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : nullIfMissing(account.get('avatar')), [avatar, account]);
|
||||
const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : nullIfMissing(account.get('header')), [header, account]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
dispatch(updateAccount({
|
||||
displayName,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
discoverable,
|
||||
indexable: discoverable,
|
||||
})).then(() => history.push('/start/follows')).catch(err => {
|
||||
setIsSaving(false);
|
||||
setErrors(err.response.data.details);
|
||||
});
|
||||
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.profile.title' defaultMessage='Profile setup' /></h3>
|
||||
<p><FormattedMessage id='onboarding.profile.lead' defaultMessage='You can always complete this later in the settings, where even more customization options are available.' /></p>
|
||||
</div>
|
||||
|
||||
<div className='simple_form'>
|
||||
<div className='onboarding__profile'>
|
||||
<label className={classNames('app-form__header-input', { selected: !!headerPreview, invalid: !!errors?.header })} title={intl.formatMessage(messages.uploadHeader)}>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={headerFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleHeaderChange}
|
||||
/>
|
||||
|
||||
{headerPreview && <img src={headerPreview} alt='' />}
|
||||
|
||||
<Icon icon={headerPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||
</label>
|
||||
|
||||
<label className={classNames('app-form__avatar-input', { selected: !!avatarPreview, invalid: !!errors?.avatar })} title={intl.formatMessage(messages.uploadAvatar)}>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={avatarFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
|
||||
{avatarPreview && <img src={avatarPreview} alt='' />}
|
||||
|
||||
<Icon icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.display_name })}>
|
||||
<label htmlFor='display_name'><FormattedMessage id='onboarding.profile.display_name' defaultMessage='Display name' /></label>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.display_name_hint' defaultMessage='Your full name or your fun name…' /></span>
|
||||
<div className='label_input'>
|
||||
<input id='display_name' type='text' value={displayName} onChange={handleDisplayNameChange} maxLength={30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.note })}>
|
||||
<label htmlFor='note'><FormattedMessage id='onboarding.profile.note' defaultMessage='Bio' /></label>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.note_hint' defaultMessage='You can @mention other people or #hashtags…' /></span>
|
||||
<div className='label_input'>
|
||||
<textarea id='note' value={note} onChange={handleNoteChange} maxLength={500} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong><FormattedMessage id='onboarding.profile.discoverable' defaultMessage='Make my profile discoverable' /></strong> <span className='recommended'><FormattedMessage id='recommended' defaultMessage='Recommended' /></span>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.discoverable_hint' defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.' /></span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Toggle checked={discoverable} onChange={handleDiscoverableChange} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Button block onClick={handleSubmit} disabled={isSaving}>{isSaving ? <LoadingIndicator /> : <FormattedMessage id='onboarding.profile.save_and_continue' defaultMessage='Save and continue' />}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
329
app/javascript/mastodon/features/onboarding/profile.tsx
Normal file
329
app/javascript/mastodon/features/onboarding/profile.tsx
Normal file
|
@ -0,0 +1,329 @@
|
|||
import { useState, useMemo, useCallback, createRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import { updateAccount } from 'mastodon/actions/accounts';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { unescapeHTML } from 'mastodon/utils/html';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'onboarding.profile.title',
|
||||
defaultMessage: 'Profile setup',
|
||||
},
|
||||
uploadHeader: {
|
||||
id: 'onboarding.profile.upload_header',
|
||||
defaultMessage: 'Upload profile header',
|
||||
},
|
||||
uploadAvatar: {
|
||||
id: 'onboarding.profile.upload_avatar',
|
||||
defaultMessage: 'Upload profile picture',
|
||||
},
|
||||
});
|
||||
|
||||
const nullIfMissing = (path: string) =>
|
||||
path.endsWith('missing.png') ? null : path;
|
||||
|
||||
interface ApiAccountErrors {
|
||||
display_name?: unknown;
|
||||
note?: unknown;
|
||||
avatar?: unknown;
|
||||
header?: unknown;
|
||||
}
|
||||
|
||||
export const Profile: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const account = useAppSelector((state) =>
|
||||
me ? state.accounts.get(me) : undefined,
|
||||
);
|
||||
const [displayName, setDisplayName] = useState(account?.display_name ?? '');
|
||||
const [note, setNote] = useState(
|
||||
account ? (unescapeHTML(account.note) ?? '') : '',
|
||||
);
|
||||
const [avatar, setAvatar] = useState<File>();
|
||||
const [header, setHeader] = useState<File>();
|
||||
const [discoverable, setDiscoverable] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ApiAccountErrors>();
|
||||
const avatarFileRef = createRef<HTMLInputElement>();
|
||||
const headerFileRef = createRef<HTMLInputElement>();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleDisplayNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDisplayName(e.target.value);
|
||||
},
|
||||
[setDisplayName],
|
||||
);
|
||||
|
||||
const handleNoteChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNote(e.target.value);
|
||||
},
|
||||
[setNote],
|
||||
);
|
||||
|
||||
const handleDiscoverableChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDiscoverable(e.target.checked);
|
||||
},
|
||||
[setDiscoverable],
|
||||
);
|
||||
|
||||
const handleAvatarChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAvatar(e.target.files?.[0]);
|
||||
},
|
||||
[setAvatar],
|
||||
);
|
||||
|
||||
const handleHeaderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHeader(e.target.files?.[0]);
|
||||
},
|
||||
[setHeader],
|
||||
);
|
||||
|
||||
const avatarPreview = useMemo(
|
||||
() =>
|
||||
avatar
|
||||
? URL.createObjectURL(avatar)
|
||||
: nullIfMissing(account?.avatar ?? 'missing.png'),
|
||||
[avatar, account],
|
||||
);
|
||||
const headerPreview = useMemo(
|
||||
() =>
|
||||
header
|
||||
? URL.createObjectURL(header)
|
||||
: nullIfMissing(account?.header ?? 'missing.png'),
|
||||
[header, account],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
dispatch(
|
||||
updateAccount({
|
||||
displayName,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
discoverable,
|
||||
indexable: discoverable,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
history.push('/start/follows');
|
||||
return '';
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (err.response) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
const { details }: { details: ApiAccountErrors } = err.response.data;
|
||||
setErrors(details);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
});
|
||||
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.title)}
|
||||
icon='person'
|
||||
iconComponent={PersonIcon}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<div className='simple_form app-form'>
|
||||
<div className='onboarding__profile'>
|
||||
<label
|
||||
className={classNames('app-form__header-input', {
|
||||
selected: !!headerPreview,
|
||||
invalid: !!errors?.header,
|
||||
})}
|
||||
title={intl.formatMessage(messages.uploadHeader)}
|
||||
>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={headerFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleHeaderChange}
|
||||
/>
|
||||
|
||||
{headerPreview && <img src={headerPreview} alt='' />}
|
||||
|
||||
<Icon
|
||||
id=''
|
||||
icon={headerPreview ? EditIcon : AddPhotoAlternateIcon}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={classNames('app-form__avatar-input', {
|
||||
selected: !!avatarPreview,
|
||||
invalid: !!errors?.avatar,
|
||||
})}
|
||||
title={intl.formatMessage(messages.uploadAvatar)}
|
||||
>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={avatarFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
|
||||
{avatarPreview && <img src={avatarPreview} alt='' />}
|
||||
|
||||
<Icon
|
||||
id=''
|
||||
icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<div
|
||||
className={classNames('input with_block_label', {
|
||||
field_with_errors: !!errors?.display_name,
|
||||
})}
|
||||
>
|
||||
<label htmlFor='display_name'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.display_name'
|
||||
defaultMessage='Display name'
|
||||
/>
|
||||
</label>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.display_name_hint'
|
||||
defaultMessage='Your full name or your fun name…'
|
||||
/>
|
||||
</span>
|
||||
<div className='label_input'>
|
||||
<input
|
||||
id='display_name'
|
||||
type='text'
|
||||
value={displayName}
|
||||
onChange={handleDisplayNameChange}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<div
|
||||
className={classNames('input with_block_label', {
|
||||
field_with_errors: !!errors?.note,
|
||||
})}
|
||||
>
|
||||
<label htmlFor='note'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.note'
|
||||
defaultMessage='Bio'
|
||||
/>
|
||||
</label>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.note_hint'
|
||||
defaultMessage='You can @mention other people or #hashtags…'
|
||||
/>
|
||||
</span>
|
||||
<div className='label_input'>
|
||||
<textarea
|
||||
id='note'
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.discoverable'
|
||||
defaultMessage='Make my profile discoverable'
|
||||
/>
|
||||
</strong>{' '}
|
||||
<span className='recommended'>
|
||||
<FormattedMessage
|
||||
id='recommended'
|
||||
defaultMessage='Recommended'
|
||||
/>
|
||||
</span>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.discoverable_hint'
|
||||
defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Toggle
|
||||
checked={discoverable}
|
||||
onChange={handleDiscoverableChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<div className='column-footer'>
|
||||
<Button block onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.save_and_continue'
|
||||
defaultMessage='Save and continue'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Profile;
|
|
@ -1,120 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
||||
import SwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { me, domain } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||
});
|
||||
|
||||
class TipCarousel extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
index: 0,
|
||||
};
|
||||
|
||||
handleSwipe = index => {
|
||||
this.setState({ index });
|
||||
};
|
||||
|
||||
handleChangeIndex = e => {
|
||||
this.setState({ index: Number(e.currentTarget.getAttribute('data-index')) });
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.setState(({ index }, { children }) => ({ index: Math.abs(index - 1) % children.length }));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.setState(({ index }, { children }) => ({ index: (index + 1) % children.length }));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
const { index } = this.state;
|
||||
|
||||
return (
|
||||
<div className='tip-carousel' tabIndex='0' onKeyDown={this.handleKeyDown}>
|
||||
<SwipeableViews onChangeIndex={this.handleSwipe} index={index} enableMouseEvents tabIndex='-1'>
|
||||
{children}
|
||||
</SwipeableViews>
|
||||
|
||||
<div className='media-modal__pagination'>
|
||||
{children.map((_, i) => (
|
||||
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const Share = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const intl = useIntl();
|
||||
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.share.title' defaultMessage='Share your profile' /></h3>
|
||||
<p><FormattedMessage id='onboarding.share.lead' defaultMessage='Let people know how they can find you on Mastodon!' /></p>
|
||||
</div>
|
||||
|
||||
<CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} />
|
||||
|
||||
<TipCarousel>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
</TipCarousel>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -8,7 +8,7 @@ import { scrollRight } from '../../../scroll';
|
|||
import BundleContainer from '../containers/bundle_container';
|
||||
import {
|
||||
Compose,
|
||||
NotificationsWrapper,
|
||||
Notifications,
|
||||
HomeTimeline,
|
||||
CommunityTimeline,
|
||||
PublicTimeline,
|
||||
|
@ -35,7 +35,7 @@ import NavigationPanel from './navigation_panel';
|
|||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
'HOME': HomeTimeline,
|
||||
'NOTIFICATIONS': NotificationsWrapper,
|
||||
'NOTIFICATIONS': Notifications,
|
||||
'PUBLIC': PublicTimeline,
|
||||
'REMOTE': PublicTimeline,
|
||||
'COMMUNITY': CommunityTimeline,
|
||||
|
|
|
@ -43,7 +43,7 @@ export const ConfirmDeleteBookmarkCategoryModal: React.FC<
|
|||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
history.push('/bookmark_categorys');
|
||||
history.push('/bookmark_categories');
|
||||
}
|
||||
}, [dispatch, history, columnId, bookmark_categoryId]);
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ import {
|
|||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
AntennaTimeline,
|
||||
NotificationsWrapper,
|
||||
Notifications,
|
||||
NotificationRequests,
|
||||
NotificationRequest,
|
||||
FollowRequests,
|
||||
|
@ -76,6 +76,10 @@ import {
|
|||
Circles,
|
||||
CircleStatuses,
|
||||
AntennaSetting,
|
||||
Directory,
|
||||
OnboardingProfile,
|
||||
OnboardingFollows,
|
||||
Explore,
|
||||
About,
|
||||
PrivacyPolicy,
|
||||
CommunityTimeline,
|
||||
|
@ -85,9 +89,6 @@ import {
|
|||
CircleMembers,
|
||||
BookmarkCategoryEdit,
|
||||
ReactionDeck,
|
||||
Onboarding,
|
||||
Directory,
|
||||
Explore,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
|
@ -244,7 +245,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path='/bookmark_categories/new' component={BookmarkCategoryEdit} content={children} />
|
||||
<WrappedRoute path='/bookmark_categories/:id/edit' component={BookmarkCategoryEdit} content={children} />
|
||||
<WrappedRoute path='/bookmark_categories/:id' component={BookmarkCategoryStatuses} content={children} />
|
||||
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
|
@ -255,7 +256,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
|
||||
<WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} />
|
||||
|
||||
<WrappedRoute path='/start' component={Onboarding} content={children} />
|
||||
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
|
|
@ -7,15 +7,7 @@ export function Compose () {
|
|||
}
|
||||
|
||||
export function Notifications () {
|
||||
return import(/* webpackChunkName: "features/notifications_v1" */'../../notifications');
|
||||
}
|
||||
|
||||
export function Notifications_v2 () {
|
||||
return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2');
|
||||
}
|
||||
|
||||
export function NotificationsWrapper () {
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications_wrapper');
|
||||
return import(/* webpackChunkName: "features/notifications" */'../../notifications_v2');
|
||||
}
|
||||
|
||||
export function HomeTimeline () {
|
||||
|
@ -218,8 +210,12 @@ export function Directory () {
|
|||
return import(/* webpackChunkName: "features/directory" */'../../directory');
|
||||
}
|
||||
|
||||
export function Onboarding () {
|
||||
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding');
|
||||
export function OnboardingProfile () {
|
||||
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/profile');
|
||||
}
|
||||
|
||||
export function OnboardingFollows () {
|
||||
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/follows');
|
||||
}
|
||||
|
||||
export function ReactionDeck () {
|
||||
|
|
|
@ -188,13 +188,13 @@
|
|||
"block_modal.title": "Block user?",
|
||||
"block_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
||||
"bookmark_categories.add_to_bookmark_categories": "Add {name} to bookmark_categories",
|
||||
"bookmark_categories.all_bookmarks": "All bookmarks",
|
||||
"bookmark_categories.bookmark_category_name": "BookmarkCategory name",
|
||||
"bookmark_categories.create": "Create",
|
||||
"bookmark_categories.create_a_bookmark_category_to_organize": "Create a new bookmark_category to organize your Home feed",
|
||||
"bookmark_categories.create_bookmark_category": "Create bookmark_category",
|
||||
"bookmark_categories.delete": "Delete category",
|
||||
"bookmark_categories.edit": "Edit category",
|
||||
"bookmark_categories.edit.submit": "Change title",
|
||||
"bookmark_categories.new_bookmark_category_name": "New bookmark_category name",
|
||||
"bookmark_categories.no_bookmark_categories_yet": "No bookmark_categories yet.",
|
||||
"bookmark_categories.save": "Save",
|
||||
|
@ -280,6 +280,7 @@
|
|||
"column_header.pin": "Pin",
|
||||
"column_header.show_settings": "Show settings",
|
||||
"column_header.unpin": "Unpin",
|
||||
"column_search.cancel": "Cancel",
|
||||
"column_subheading.settings": "Settings",
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.media_only": "Media Only",
|
||||
|
@ -809,45 +810,21 @@
|
|||
"notifications_permission_banner.enable": "Enable desktop notifications",
|
||||
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
|
||||
"notifications_permission_banner.title": "Never miss a thing",
|
||||
"onboarding.action.back": "Take me back",
|
||||
"onboarding.actions.back": "Take me back",
|
||||
"onboarding.actions.go_to_explore": "Take me to trending",
|
||||
"onboarding.actions.go_to_home": "Take me to my home feed",
|
||||
"onboarding.actions.go_to_local_timeline": "See posts from local",
|
||||
"onboarding.compose.template": "Hello #Mastodon!",
|
||||
"onboarding.follows.back": "Back",
|
||||
"onboarding.follows.done": "Done",
|
||||
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
|
||||
"onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
|
||||
"onboarding.follows.title": "Personalize your home feed",
|
||||
"onboarding.follows.search": "Search",
|
||||
"onboarding.follows.title": "Follow people to get started",
|
||||
"onboarding.profile.discoverable": "Make my profile discoverable",
|
||||
"onboarding.profile.discoverable_hint": "When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.",
|
||||
"onboarding.profile.display_name": "Display name",
|
||||
"onboarding.profile.display_name_hint": "Your full name or your fun name…",
|
||||
"onboarding.profile.lead": "You can always complete this later in the settings, where even more customization options are available.",
|
||||
"onboarding.profile.note": "Bio",
|
||||
"onboarding.profile.note_hint": "You can @mention other people or #hashtags…",
|
||||
"onboarding.profile.save_and_continue": "Save and continue",
|
||||
"onboarding.profile.title": "Profile setup",
|
||||
"onboarding.profile.upload_avatar": "Upload profile picture",
|
||||
"onboarding.profile.upload_header": "Upload profile header",
|
||||
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
|
||||
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
|
||||
"onboarding.share.next_steps": "Possible next steps:",
|
||||
"onboarding.share.title": "Share your profile",
|
||||
"onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:",
|
||||
"onboarding.start.skip": "Don't need help getting started?",
|
||||
"onboarding.start.title": "You've made it!",
|
||||
"onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
|
||||
"onboarding.steps.follow_people.title": "Personalize your home feed",
|
||||
"onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}",
|
||||
"onboarding.steps.publish_status.title": "Make your first post",
|
||||
"onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.",
|
||||
"onboarding.steps.setup_profile.title": "Personalize your profile",
|
||||
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon",
|
||||
"onboarding.steps.share_profile.title": "Share your Mastodon profile",
|
||||
"onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
|
||||
"onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!",
|
||||
"onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",
|
||||
"onboarding.tips.verification": "<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!",
|
||||
"password_confirmation.exceeds_maxlength": "Password confirmation exceeds the maximum password length",
|
||||
"password_confirmation.mismatching": "Password confirmation does not match",
|
||||
"picture_in_picture.restore": "Put it back",
|
||||
|
|
|
@ -26,8 +26,8 @@
|
|||
"account.domain_blocked": "域名已屏蔽",
|
||||
"account.edit_profile": "修改个人资料",
|
||||
"account.enable_notifications": "当 @{name} 发布嘟文时通知我",
|
||||
"account.endorse": "在个人资料中推荐此用户",
|
||||
"account.featured_tags.last_status_at": "最近发言于 {date}",
|
||||
"account.endorse": "在账户页推荐此用户",
|
||||
"account.featured_tags.last_status_at": "上次发言于 {date}",
|
||||
"account.featured_tags.last_status_never": "暂无嘟文",
|
||||
"account.featured_tags.title": "{name} 的精选标签",
|
||||
"account.follow": "关注",
|
||||
|
@ -105,7 +105,7 @@
|
|||
"annual_report.summary.new_posts.new_posts": "发嘟",
|
||||
"annual_report.summary.percentile.text": "<topLabel>这使你跻身 Mastodon 用户的前</topLabel><percentage></percentage><bottomLabel></bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "我们打死也不会告诉扣税国王的(他知道的话要来收你发嘟税了)。",
|
||||
"annual_report.summary.thanks": "感谢你这一年与 Mastodon 一路同行!",
|
||||
"annual_report.summary.thanks": "感谢你这一年和 Mastodon 上的大家一起嘟嘟!",
|
||||
"attachments_list.unprocessed": "(未处理)",
|
||||
"audio.hide": "隐藏音频",
|
||||
"block_modal.remote_users_caveat": "我们将要求服务器 {domain} 尊重你的决定。然而,我们无法保证对方一定遵从,因为某些服务器可能会以不同的方案处理屏蔽操作。公开嘟文仍然可能对未登录的用户可见。",
|
||||
|
@ -138,7 +138,7 @@
|
|||
"closed_registrations_modal.title": "注册 Mastodon 账号",
|
||||
"column.about": "关于",
|
||||
"column.blocks": "屏蔽的用户",
|
||||
"column.bookmarks": "书签",
|
||||
"column.bookmarks": "收藏夹",
|
||||
"column.community": "本站时间线",
|
||||
"column.create_list": "创建列表",
|
||||
"column.direct": "私下提及",
|
||||
|
@ -510,7 +510,7 @@
|
|||
"navigation_bar.administration": "管理",
|
||||
"navigation_bar.advanced_interface": "在高级网页界面中打开",
|
||||
"navigation_bar.blocks": "已屏蔽的用户",
|
||||
"navigation_bar.bookmarks": "书签",
|
||||
"navigation_bar.bookmarks": "收藏夹",
|
||||
"navigation_bar.community_timeline": "本站时间线",
|
||||
"navigation_bar.compose": "撰写新嘟文",
|
||||
"navigation_bar.direct": "私下提及",
|
||||
|
@ -555,7 +555,7 @@
|
|||
"notification.label.reply": "回复",
|
||||
"notification.mention": "提及",
|
||||
"notification.mentioned_you": "{name} 提到了你",
|
||||
"notification.moderation-warning.learn_more": "了解更多",
|
||||
"notification.moderation-warning.learn_more": "详细了解",
|
||||
"notification.moderation_warning": "你收到了一条管理警告",
|
||||
"notification.moderation_warning.action_delete_statuses": "你的一些嘟文已被移除。",
|
||||
"notification.moderation_warning.action_disable": "你的账号已被禁用。",
|
||||
|
@ -571,7 +571,7 @@
|
|||
"notification.relationships_severance_event": "与 {name} 的联系已断开",
|
||||
"notification.relationships_severance_event.account_suspension": "{from} 的管理员封禁了 {target},这意味着你将无法再收到对方的更新或与其互动。",
|
||||
"notification.relationships_severance_event.domain_block": "{from} 的管理员屏蔽了 {target},其中包括你的 {followersCount} 个关注者和 {followingCount, plural, other {# 个关注}}。",
|
||||
"notification.relationships_severance_event.learn_more": "了解更多",
|
||||
"notification.relationships_severance_event.learn_more": "详细了解",
|
||||
"notification.relationships_severance_event.user_domain_block": "你已经屏蔽了 {target},移除了你的 {followersCount} 个关注者和 {followingCount, plural, other {# 个关注}}。",
|
||||
"notification.status": "{name} 刚刚发布嘟文",
|
||||
"notification.update": "{name} 编辑了嘟文",
|
||||
|
@ -717,11 +717,11 @@
|
|||
"regeneration_indicator.label": "加载中…",
|
||||
"regeneration_indicator.sublabel": "你的主页动态正在准备中!",
|
||||
"relative_time.days": "{number} 天前",
|
||||
"relative_time.full.days": "{number, plural, one {# 天} other {# 天}}前",
|
||||
"relative_time.full.hours": "{number, plural, one {# 小时} other {# 小时}}前",
|
||||
"relative_time.full.days": "{number, plural, other {# 天}}前",
|
||||
"relative_time.full.hours": "{number, plural, other {# 小时}}前",
|
||||
"relative_time.full.just_now": "刚刚",
|
||||
"relative_time.full.minutes": "{number, plural, one {# 分钟} other {# 分钟}}前",
|
||||
"relative_time.full.seconds": "{number, plural, one {# 秒} other {# 秒}}前",
|
||||
"relative_time.full.minutes": "{number, plural, other {# 分钟}}前",
|
||||
"relative_time.full.seconds": "{number, plural, other {# 秒}}前",
|
||||
"relative_time.hours": "{number} 小时前",
|
||||
"relative_time.just_now": "刚刚",
|
||||
"relative_time.minutes": "{number} 分钟前",
|
||||
|
@ -817,7 +817,7 @@
|
|||
"status.admin_domain": "打开 {domain} 的管理界面",
|
||||
"status.admin_status": "打开此帖的管理界面",
|
||||
"status.block": "屏蔽 @{name}",
|
||||
"status.bookmark": "添加到书签",
|
||||
"status.bookmark": "收藏",
|
||||
"status.cancel_reblog_private": "取消转贴",
|
||||
"status.cannot_reblog": "这条嘟文不允许被转嘟",
|
||||
"status.continued_thread": "上接嘟文串",
|
||||
|
@ -853,7 +853,7 @@
|
|||
"status.reblogs": "{count, plural, other {次转嘟}}",
|
||||
"status.reblogs.empty": "没有人转嘟过此条嘟文。如果有人转嘟了,就会显示在这里。",
|
||||
"status.redraft": "删除并重新编辑",
|
||||
"status.remove_bookmark": "移除书签",
|
||||
"status.remove_bookmark": "取消收藏",
|
||||
"status.replied_in_thread": "回复给嘟文串",
|
||||
"status.replied_to": "回复给 {name}",
|
||||
"status.reply": "回复",
|
||||
|
@ -875,11 +875,11 @@
|
|||
"subscribed_languages.target": "更改 {target} 的订阅语言",
|
||||
"tabs_bar.home": "主页",
|
||||
"tabs_bar.notifications": "通知",
|
||||
"time_remaining.days": "剩余 {number, plural, one {# 天} other {# 天}}",
|
||||
"time_remaining.hours": "剩余 {number, plural, one {# 小时} other {# 小时}}",
|
||||
"time_remaining.minutes": "剩余 {number, plural, one {# 分钟} other {# 分钟}}",
|
||||
"time_remaining.days": "剩余 {number, plural, other {# 天}}",
|
||||
"time_remaining.hours": "剩余 {number, plural, other {# 小时}}",
|
||||
"time_remaining.minutes": "剩余 {number, plural, other {# 分钟}}",
|
||||
"time_remaining.moments": "即将结束",
|
||||
"time_remaining.seconds": "剩余 {number, plural, one {# 秒} other {# 秒}}",
|
||||
"time_remaining.seconds": "剩余 {number, plural, other {# 秒}}",
|
||||
"trends.counter_by_accounts": "过去 {days, plural, other {{days} 天}}有{count, plural, other { {counter} 人}}讨论",
|
||||
"trends.trending_now": "当前热门",
|
||||
"ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会丢失。",
|
||||
|
|
12
app/javascript/mastodon/models/suggestion.ts
Normal file
12
app/javascript/mastodon/models/suggestion.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
|
||||
|
||||
export interface Suggestion extends Omit<ApiSuggestionJSON, 'account'> {
|
||||
account_id: string;
|
||||
}
|
||||
|
||||
export const createSuggestion = (
|
||||
serverJSON: ApiSuggestionJSON,
|
||||
): Suggestion => ({
|
||||
sources: serverJSON.sources,
|
||||
account_id: serverJSON.account.id,
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import {
|
||||
BOOKMARK_CATEGORY_ADDER_RESET,
|
||||
BOOKMARK_CATEGORY_ADDER_SETUP,
|
||||
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
|
||||
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
|
||||
BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
|
||||
} from '../actions/bookmark_categories';
|
||||
import {
|
||||
UNBOOKMARK_SUCCESS,
|
||||
} from '../actions/interactions';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
statusId: null,
|
||||
|
||||
bookmarkCategories: ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
loaded: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function bookmarkCategoryAdderReducer(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case BOOKMARK_CATEGORY_ADDER_RESET:
|
||||
return initialState;
|
||||
case BOOKMARK_CATEGORY_ADDER_SETUP:
|
||||
return state.withMutations(map => {
|
||||
map.set('statusId', action.status.get('id'));
|
||||
});
|
||||
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST:
|
||||
return state.setIn(['bookmarkCategories', 'isLoading'], true);
|
||||
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL:
|
||||
return state.setIn(['bookmarkCategories', 'isLoading'], false);
|
||||
case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS:
|
||||
return state.update('bookmarkCategories', bookmarkCategories => bookmarkCategories.withMutations(map => {
|
||||
map.set('isLoading', false);
|
||||
map.set('loaded', true);
|
||||
map.set('items', ImmutableList(action.bookmarkCategories.map(item => item.id)));
|
||||
}));
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
|
||||
return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.unshift(action.bookmarkCategoryId));
|
||||
case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS:
|
||||
return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.filterNot(item => item === action.bookmarkCategoryId));
|
||||
case UNBOOKMARK_SUCCESS:
|
||||
return action.status.get('id') === state.get('statusId') ? state.setIn(['bookmarkCategories', 'items'], ImmutableList()) : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import {
|
||||
BOOKMARK_CATEGORY_CREATE_REQUEST,
|
||||
BOOKMARK_CATEGORY_CREATE_FAIL,
|
||||
BOOKMARK_CATEGORY_CREATE_SUCCESS,
|
||||
BOOKMARK_CATEGORY_UPDATE_REQUEST,
|
||||
BOOKMARK_CATEGORY_UPDATE_FAIL,
|
||||
BOOKMARK_CATEGORY_UPDATE_SUCCESS,
|
||||
BOOKMARK_CATEGORY_EDITOR_RESET,
|
||||
BOOKMARK_CATEGORY_EDITOR_SETUP,
|
||||
BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
|
||||
} from '../actions/bookmark_categories';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
bookmarkCategoryId: null,
|
||||
isSubmitting: false,
|
||||
isChanged: false,
|
||||
title: '',
|
||||
isExclusive: false,
|
||||
|
||||
statuses: ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
loaded: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
suggestions: ImmutableMap({
|
||||
value: '',
|
||||
items: ImmutableList(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default function bookmarkCategoryEditorReducer(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case BOOKMARK_CATEGORY_EDITOR_RESET:
|
||||
return initialState;
|
||||
case BOOKMARK_CATEGORY_EDITOR_SETUP:
|
||||
return state.withMutations(map => {
|
||||
map.set('bookmarkCategoryId', action.bookmarkCategory.get('id'));
|
||||
map.set('title', action.bookmarkCategory.get('title'));
|
||||
map.set('isSubmitting', false);
|
||||
});
|
||||
case BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
map.set('title', action.value);
|
||||
map.set('isChanged', true);
|
||||
});
|
||||
case BOOKMARK_CATEGORY_CREATE_REQUEST:
|
||||
case BOOKMARK_CATEGORY_UPDATE_REQUEST:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', true);
|
||||
map.set('isChanged', false);
|
||||
});
|
||||
case BOOKMARK_CATEGORY_CREATE_FAIL:
|
||||
case BOOKMARK_CATEGORY_UPDATE_FAIL:
|
||||
return state.set('isSubmitting', false);
|
||||
case BOOKMARK_CATEGORY_CREATE_SUCCESS:
|
||||
case BOOKMARK_CATEGORY_UPDATE_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('isSubmitting', false);
|
||||
map.set('bookmarkCategoryId', action.bookmarkCategory.id);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -39,7 +39,7 @@ import server from './server';
|
|||
import settings from './settings';
|
||||
import status_lists from './status_lists';
|
||||
import statuses from './statuses';
|
||||
import suggestions from './suggestions';
|
||||
import { suggestionsReducer } from './suggestions';
|
||||
import tags from './tags';
|
||||
import timelines from './timelines';
|
||||
import trends from './trends';
|
||||
|
@ -77,7 +77,7 @@ const reducers = {
|
|||
bookmark_categories: bookmarkCategoriesReducer,
|
||||
filters,
|
||||
conversations,
|
||||
suggestions,
|
||||
suggestions: suggestionsReducer,
|
||||
polls,
|
||||
trends,
|
||||
markers: markersReducer,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import { BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS, BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS, BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS } from 'mastodon/actions/bookmark_categories';
|
||||
import { CIRCLE_STATUSES_EXPAND_SUCCESS, CIRCLE_STATUSES_FETCH_SUCCESS } from 'mastodon/actions/circles';
|
||||
import { COMPOSE_WITH_CIRCLE_SUCCESS } from 'mastodon/actions/compose';
|
||||
|
||||
import {
|
||||
blockAccountSuccess,
|
||||
muteAccountSuccess,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
} from '../actions/bookmark_categories';
|
||||
import {
|
||||
BOOKMARKED_STATUSES_FETCH_REQUEST,
|
||||
BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||
|
@ -54,7 +55,6 @@ import {
|
|||
} from '../actions/trends';
|
||||
|
||||
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
favourites: ImmutableMap({
|
||||
next: null,
|
||||
|
@ -81,9 +81,31 @@ const initialState = ImmutableMap({
|
|||
loaded: false,
|
||||
items: ImmutableOrderedSet(),
|
||||
}),
|
||||
circle_statuses: ImmutableMap(),
|
||||
bookmark_category_statuses: ImmutableMap(),
|
||||
});
|
||||
|
||||
const normalizeList = (state, listType, statuses, next) => {
|
||||
if (Array.isArray(listType)) {
|
||||
if (state.getIn(listType)) {
|
||||
return state.updateIn(listType, listMap => {
|
||||
return listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
map.set('loaded', true);
|
||||
map.set('isLoading', false);
|
||||
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return state.setIn(listType, ImmutableMap({
|
||||
next: next,
|
||||
loaded: true,
|
||||
isLoading: false,
|
||||
items: ImmutableOrderedSet(statuses.map(item => item.id)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return state.update(listType, listMap => listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
map.set('loaded', true);
|
||||
|
@ -93,6 +115,14 @@ const normalizeList = (state, listType, statuses, next) => {
|
|||
};
|
||||
|
||||
const appendToList = (state, listType, statuses, next) => {
|
||||
if (Array.isArray(listType)) {
|
||||
return state.updateIn(listType, listMap => listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
map.set('isLoading', false);
|
||||
map.set('items', map.get('items').union(statuses.map(item => item.id)));
|
||||
}));
|
||||
}
|
||||
|
||||
return state.update(listType, listMap => listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
map.set('isLoading', false);
|
||||
|
@ -105,6 +135,16 @@ const prependOneToList = (state, listType, status) => {
|
|||
};
|
||||
|
||||
const prependOneToListById = (state, listType, statusId) => {
|
||||
if (Array.isArray(listType)) {
|
||||
if (!state.getIn(listType)) return state;
|
||||
|
||||
return state.updateIn(listType, item => item.withMutations(map => {
|
||||
if (map.get('items')) {
|
||||
map.update('items', list => ImmutableOrderedSet([statusId]).union(list));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return state.updateIn([listType, 'items'], (list) => {
|
||||
if (list.includes(statusId)) {
|
||||
return list;
|
||||
|
@ -118,6 +158,32 @@ const removeOneFromList = (state, listType, status) => {
|
|||
return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id')));
|
||||
};
|
||||
|
||||
const removeOneFromListById = (state, listType, statusId) => {
|
||||
if (Array.isArray(listType)) {
|
||||
if (!state.getIn(listType)) return state;
|
||||
|
||||
return state.updateIn(listType, item => item.withMutations(map => {
|
||||
if (map.get('items')) {
|
||||
map.update('items', list => list.delete(statusId));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return state.update(listType, item => item.withMutations(map => {
|
||||
if (map.get('items')) {
|
||||
map.update('items', list => list.delete(statusId));
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const removeOneFromAllBookmarkCategoriesById = (state, statusId) => {
|
||||
let s = state;
|
||||
state.get('bookmark_category_statuses').forEach((category) => {
|
||||
s = s.updateIn(['bookmark_category_statuses', category.get('id'), 'items'], list => list?.delete(statusId));
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
export default function statusLists(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FAVOURITED_STATUSES_FETCH_REQUEST:
|
||||
|
@ -140,6 +206,16 @@ export default function statusLists(state = initialState, action) {
|
|||
return normalizeList(state, 'emoji_reactions', action.statuses, action.next);
|
||||
case EMOJI_REACTED_STATUSES_EXPAND_SUCCESS:
|
||||
return appendToList(state, 'emoji_reactions', action.statuses, action.next);
|
||||
case CIRCLE_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['circle_statuses', action.id], action.statuses, action.next);
|
||||
case CIRCLE_STATUSES_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['circle_statuses', action.id], action.statuses, action.next);
|
||||
case COMPOSE_WITH_CIRCLE_SUCCESS:
|
||||
return prependOneToListById(state, ['circle_statuses', action.circleId], action.statusId);
|
||||
case BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['bookmark_category_statuses', action.id], action.statuses, action.next);
|
||||
case BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['bookmark_category_statuses', action.id], action.statuses, action.next);
|
||||
case BOOKMARKED_STATUSES_FETCH_REQUEST:
|
||||
case BOOKMARKED_STATUSES_EXPAND_REQUEST:
|
||||
return state.setIn(['bookmarks', 'isLoading'], true);
|
||||
|
@ -171,9 +247,17 @@ export default function statusLists(state = initialState, action) {
|
|||
case BOOKMARK_SUCCESS:
|
||||
return prependOneToList(state, 'bookmarks', action.status);
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
|
||||
return prependOneToListById(state, 'bookmarks', action.statusId);
|
||||
{
|
||||
const s = prependOneToListById(state, 'bookmarks', action.statusId);
|
||||
return prependOneToListById(s, ['bookmark_category_statuses', action.id], action.statusId);
|
||||
}
|
||||
case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS:
|
||||
return removeOneFromListById(state, ['bookmark_category_statuses', action.id], action.statusId);
|
||||
case UNBOOKMARK_SUCCESS:
|
||||
return removeOneFromList(state, 'bookmarks', action.status);
|
||||
{
|
||||
const s = removeOneFromList(state, 'bookmarks', action.status);
|
||||
return removeOneFromAllBookmarkCategoriesById(s, action.statusId);
|
||||
}
|
||||
case PINNED_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'pins', action.statuses, action.next);
|
||||
case PIN_SUCCESS:
|
||||
|
|
|
@ -4,8 +4,7 @@ import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
|||
import { me } from 'mastodon/initial_state';
|
||||
|
||||
import {
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
|
||||
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
|
||||
} from '../actions/bookmark_categories';
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
||||
|
@ -122,10 +121,8 @@ export default function statuses(state = initialState, action) {
|
|||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
|
||||
case BOOKMARK_FAIL:
|
||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST:
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
|
||||
return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], true);
|
||||
case BOOKMARK_CATEGORY_EDITOR_ADD_FAIL:
|
||||
return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], false);
|
||||
case UNBOOKMARK_REQUEST:
|
||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
||||
case UNBOOKMARK_FAIL:
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
|
||||
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
|
||||
|
||||
import {
|
||||
SUGGESTIONS_FETCH_REQUEST,
|
||||
SUGGESTIONS_FETCH_SUCCESS,
|
||||
SUGGESTIONS_FETCH_FAIL,
|
||||
SUGGESTIONS_DISMISS,
|
||||
} from '../actions/suggestions';
|
||||
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export default function suggestionsReducer(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case SUGGESTIONS_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case SUGGESTIONS_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
|
||||
map.set('isLoading', false);
|
||||
});
|
||||
case SUGGESTIONS_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case SUGGESTIONS_DISMISS:
|
||||
return state.update('items', list => list.filterNot(x => x.get('account') === action.id));
|
||||
case blockAccountSuccess.type:
|
||||
case muteAccountSuccess.type:
|
||||
return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id));
|
||||
case blockDomainSuccess.type:
|
||||
return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account'))));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
60
app/javascript/mastodon/reducers/suggestions.ts
Normal file
60
app/javascript/mastodon/reducers/suggestions.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
blockAccountSuccess,
|
||||
muteAccountSuccess,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
|
||||
import {
|
||||
fetchSuggestions,
|
||||
dismissSuggestion,
|
||||
} from 'mastodon/actions/suggestions';
|
||||
import { createSuggestion } from 'mastodon/models/suggestion';
|
||||
import type { Suggestion } from 'mastodon/models/suggestion';
|
||||
|
||||
interface State {
|
||||
items: Suggestion[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export const suggestionsReducer = createReducer(initialState, (builder) => {
|
||||
builder.addCase(fetchSuggestions.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
});
|
||||
|
||||
builder.addCase(fetchSuggestions.fulfilled, (state, action) => {
|
||||
state.items = action.payload.map(createSuggestion);
|
||||
state.isLoading = false;
|
||||
});
|
||||
|
||||
builder.addCase(fetchSuggestions.rejected, (state) => {
|
||||
state.isLoading = false;
|
||||
});
|
||||
|
||||
builder.addCase(dismissSuggestion.pending, (state, action) => {
|
||||
state.items = state.items.filter(
|
||||
(x) => x.account_id !== action.meta.arg.accountId,
|
||||
);
|
||||
});
|
||||
|
||||
builder.addCase(blockDomainSuccess, (state, action) => {
|
||||
state.items = state.items.filter(
|
||||
(x) =>
|
||||
!action.payload.accounts.some((account) => account.id === x.account_id),
|
||||
);
|
||||
});
|
||||
|
||||
builder.addMatcher(
|
||||
isAnyOf(blockAccountSuccess, muteAccountSuccess),
|
||||
(state, action) => {
|
||||
state.items = state.items.filter(
|
||||
(x) => x.account_id !== action.payload.relationship.id,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
|
@ -139,9 +139,9 @@ export const getStatusList = createSelector([
|
|||
], (items) => items.toList());
|
||||
|
||||
export const getBookmarkCategoryStatusList = createSelector([
|
||||
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
|
||||
(state, bookmarkCategoryId) => state.getIn(['status_lists', 'bookmark_category_statuses', bookmarkCategoryId, 'items']),
|
||||
], (items) => items ? items.toList() : ImmutableList());
|
||||
|
||||
export const getCircleStatusList = createSelector([
|
||||
(state, circleId) => state.getIn(['circles', circleId, 'items']),
|
||||
(state, circleId) => state.getIn(['status_lists', 'circle_statuses', circleId, `items`]),
|
||||
], (items) => items ? items.toList() : ImmutableList());
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue