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
15
Gemfile.lock
15
Gemfile.lock
|
@ -93,10 +93,9 @@ GEM
|
|||
annotaterb (4.13.0)
|
||||
ast (2.4.2)
|
||||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1012.0)
|
||||
aws-sdk-core (3.213.0)
|
||||
aws-partitions (1.1013.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
@ -104,7 +103,7 @@ GEM
|
|||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.173.0)
|
||||
aws-sdk-s3 (1.174.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
|
@ -349,7 +348,8 @@ GEM
|
|||
json-schema (5.1.0)
|
||||
addressable (~> 2.8)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.7.1)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
|
@ -752,7 +752,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.3.2)
|
||||
selenium-webdriver (4.26.0)
|
||||
selenium-webdriver (4.27.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
|
@ -844,9 +844,8 @@ GEM
|
|||
public_suffix
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
webauthn (3.1.0)
|
||||
webauthn (3.2.2)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
awrence (~> 1.1)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
|
|
|
@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base
|
|||
helper_method :use_seamless_external_login?
|
||||
helper_method :sso_account_settings
|
||||
helper_method :limited_federation_mode?
|
||||
helper_method :body_class_string
|
||||
helper_method :skip_csrf_meta_tags?
|
||||
|
||||
rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
|
||||
|
@ -158,10 +157,6 @@ class ApplicationController < ActionController::Base
|
|||
current_user.setting_theme
|
||||
end
|
||||
|
||||
def body_class_string
|
||||
@body_classes || ''
|
||||
end
|
||||
|
||||
def respond_with_error(code)
|
||||
respond_to do |format|
|
||||
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
|
||||
|
|
|
@ -38,7 +38,7 @@ module CacheConcern
|
|||
return render(options)
|
||||
end
|
||||
|
||||
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':')
|
||||
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':')
|
||||
expires_in = options.delete(:expires_in) || 3.minutes
|
||||
body = Rails.cache.read(key, raw: true)
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def body_classes
|
||||
output = body_class_string.split
|
||||
output = []
|
||||
output << content_for(:body_classes)
|
||||
output << "theme-#{current_theme.parameterize}"
|
||||
output << 'system-font' if current_account&.user&.setting_system_font_ui
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -447,17 +447,26 @@
|
|||
color: lighten($ui-highlight-color, 8%);
|
||||
}
|
||||
|
||||
.compose-form .autosuggest-textarea__textarea,
|
||||
.compose-form__highlightable,
|
||||
.autosuggest-textarea__suggestions,
|
||||
.search__input,
|
||||
.search__popout,
|
||||
.emoji-mart-search input,
|
||||
.language-dropdown__dropdown .emoji-mart-search input,
|
||||
.poll__option input[type='text'] {
|
||||
background: darken($ui-base-color, 10%);
|
||||
}
|
||||
|
||||
.search__popout__menu__item {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&.active {
|
||||
color: $white;
|
||||
|
||||
mark,
|
||||
.icon-button {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-follow-suggestions {
|
||||
background-color: rgba($ui-highlight-color, 0.1);
|
||||
border-bottom-color: rgba($ui-highlight-color, 0.3);
|
||||
|
|
|
@ -83,4 +83,5 @@ body {
|
|||
--rich-text-text-color: rgba(114, 47, 83, 100%);
|
||||
--rich-text-decorations-color: rgba(255, 175, 212, 100%);
|
||||
--input-placeholder-color: #{transparentize($dark-text-color, 0.5)};
|
||||
--input-background-color: #{darken($ui-base-color, 10%)};
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
width: 100%;
|
||||
box-shadow: none;
|
||||
font-family: inherit;
|
||||
background: $ui-base-color;
|
||||
background: var(--input-background-color);
|
||||
color: $darker-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
|
|
|
@ -44,6 +44,10 @@
|
|||
color: $ui-primary-color;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: $ui-button-icon-focus-outline;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
|
@ -419,10 +423,10 @@ body > [data-popper-placement] {
|
|||
|
||||
&__suggestions {
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
background: $ui-base-color;
|
||||
background: var(--input-background-color);
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: $secondary-text-color;
|
||||
color: var(--on-input-color);
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
|
||||
|
@ -435,7 +439,7 @@ body > [data-popper-placement] {
|
|||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
color: $secondary-text-color;
|
||||
color: var(--on-input-color);
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
@ -549,7 +553,7 @@ body > [data-popper-placement] {
|
|||
transition: border-color 300ms linear;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: $ui-base-color;
|
||||
background: var(--input-background-color);
|
||||
overflow-y: auto;
|
||||
|
||||
&.active {
|
||||
|
@ -626,7 +630,7 @@ body > [data-popper-placement] {
|
|||
width: 100%;
|
||||
margin: 0;
|
||||
color: $secondary-text-color;
|
||||
background: $ui-base-color;
|
||||
background: var(--input-background-color);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
|
@ -3341,203 +3345,6 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
}
|
||||
}
|
||||
|
||||
.onboarding__footer {
|
||||
margin-top: 30px;
|
||||
color: $dark-text-color;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
|
||||
.link-button {
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: $highlight-text-color;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: 56px;
|
||||
text-decoration: none;
|
||||
|
||||
svg {
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding__illustration {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 10px;
|
||||
max-height: 200px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.onboarding__lead {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
color: $darker-text-color;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: $secondary-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding__links {
|
||||
margin-bottom: 30px;
|
||||
|
||||
& > * {
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding__steps {
|
||||
margin-bottom: 30px;
|
||||
|
||||
&__item {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
padding-inline-end: 15px;
|
||||
margin-bottom: 2px;
|
||||
text-decoration: none;
|
||||
text-align: start;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: $highlight-text-color;
|
||||
font-size: 1.2rem;
|
||||
|
||||
@media screen and (width >= 600px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
flex: 0 0 auto;
|
||||
background: $valid-value-color;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
color: $primary-text-color;
|
||||
|
||||
svg {
|
||||
height: 14px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__go {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
color: $highlight-text-color;
|
||||
font-size: 17px;
|
||||
|
||||
svg {
|
||||
height: 1.5em;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
flex: 1 1 auto;
|
||||
line-height: 20px;
|
||||
|
||||
h6 {
|
||||
color: $highlight-text-color;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $darker-text-color;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.follow-recommendations {
|
||||
background: darken($ui-base-color, 4%);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.account:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
color: $darker-text-color;
|
||||
font-weight: 500;
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-carousel {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: $highlight-text-color;
|
||||
}
|
||||
|
||||
.media-modal__pagination {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-paste-text {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
border-radius: 8px;
|
||||
|
@ -3584,9 +3391,10 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
.onboarding__profile {
|
||||
position: relative;
|
||||
margin-bottom: 40px + 20px;
|
||||
margin-top: -20px;
|
||||
|
||||
.app-form__avatar-input {
|
||||
border: 2px solid $ui-base-color;
|
||||
border: 2px solid var(--background-color);
|
||||
position: absolute;
|
||||
inset-inline-start: -2px;
|
||||
bottom: -40px;
|
||||
|
@ -5851,7 +5659,7 @@ a.status-card {
|
|||
inset-inline-start: 0;
|
||||
margin-top: -2px;
|
||||
width: 100%;
|
||||
background: $ui-base-color;
|
||||
background: var(--input-background-color);
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
|
@ -11583,19 +11391,22 @@ noscript {
|
|||
|
||||
.column-search-header {
|
||||
display: flex;
|
||||
border-radius: 4px 4px 0 0;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--background-border-color);
|
||||
|
||||
.column-header__back-button.compact {
|
||||
flex: 0 0 auto;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
padding: 16px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: $primary-text-color;
|
||||
background: var(--input-background-color);
|
||||
border: 1px solid var(--background-border-color);
|
||||
color: var(--on-input-color);
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
|
||||
|
|
|
@ -1271,13 +1271,13 @@ code {
|
|||
}
|
||||
|
||||
.app-form {
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
|
||||
&__avatar-input,
|
||||
&__header-input {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: var(--dropdown-background-color);
|
||||
background: var(--surface-variant-background-color);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
|
@ -1325,7 +1325,7 @@ code {
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--dropdown-border-color);
|
||||
background-color: var(--surface-variant-active-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -131,4 +131,6 @@ $font-monospace: 'mastodon-font-monospace' !default;
|
|||
--rich-text-text-color: rgba(255, 175, 212, 100%);
|
||||
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
|
||||
--input-placeholder-color: #{$dark-text-color};
|
||||
--input-background-color: var(--surface-variant-background-color);
|
||||
--on-input-color: #{$secondary-text-color};
|
||||
}
|
||||
|
|
|
@ -3,9 +3,8 @@
|
|||
module Account::Avatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
|
||||
AVATAR_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
AVATAR_LIMIT = Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes
|
||||
AVATAR_DIMENSIONS = [400, 400].freeze
|
||||
AVATAR_GEOMETRY = [AVATAR_DIMENSIONS.first, AVATAR_DIMENSIONS.last].join('x')
|
||||
|
||||
|
@ -22,9 +21,9 @@ module Account::Avatar
|
|||
included do
|
||||
# Avatar upload
|
||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: LIMIT
|
||||
remotable_attachment :avatar, LIMIT, suppress_errors: false
|
||||
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: AVATAR_LIMIT
|
||||
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false
|
||||
end
|
||||
|
||||
def avatar_original_url
|
||||
|
|
|
@ -3,16 +3,15 @@
|
|||
module Account::Header
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
|
||||
HEADER_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
HEADER_LIMIT = Rails.configuration.x.use_vips ? 8.megabytes : 2.megabytes
|
||||
HEADER_DIMENSIONS = [1500, 500].freeze
|
||||
HEADER_GEOMETRY = [HEADER_DIMENSIONS.first, HEADER_DIMENSIONS.last].join('x')
|
||||
MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last
|
||||
HEADER_MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last
|
||||
|
||||
class_methods do
|
||||
def header_styles(file)
|
||||
styles = { original: { pixels: MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
|
||||
styles = { original: { pixels: HEADER_MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
end
|
||||
|
@ -23,9 +22,9 @@ module Account::Header
|
|||
included do
|
||||
# Header upload
|
||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: LIMIT
|
||||
remotable_attachment :header, LIMIT, suppress_errors: false
|
||||
validates_attachment_content_type :header, content_type: HEADER_IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: HEADER_LIMIT
|
||||
remotable_attachment :header, HEADER_LIMIT, suppress_errors: false
|
||||
end
|
||||
|
||||
def header_original_url
|
||||
|
|
|
@ -147,6 +147,10 @@ class UserRole < ApplicationRecord
|
|||
other_role.nil? || position > other_role.position
|
||||
end
|
||||
|
||||
def bypass_block?(role)
|
||||
overrides?(role) && highlighted? && can?(*Flags::CATEGORIES[:moderation])
|
||||
end
|
||||
|
||||
def computed_permissions
|
||||
# If called on the everyone role, no further computation needed
|
||||
return permissions if everyone?
|
||||
|
|
|
@ -137,7 +137,7 @@ class NotifyService < BaseService
|
|||
end
|
||||
|
||||
def from_staff?
|
||||
@sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role) && @sender.user_role&.highlighted? && @sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation])
|
||||
@sender.local? && @sender.user.present? && @sender.user_role&.bypass_block?(@recipient.user_role)
|
||||
end
|
||||
|
||||
def from_self?
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= f.input :avatar,
|
||||
hint: t('simple_form.hints.defaults.avatar', dimensions: Account::Avatar::AVATAR_GEOMETRY, size: number_to_human_size(Account::Avatar::LIMIT)),
|
||||
input_html: { accept: Account::Avatar::IMAGE_MIME_TYPES.join(',') },
|
||||
hint: t('simple_form.hints.defaults.avatar', dimensions: Account::Avatar::AVATAR_GEOMETRY, size: number_to_human_size(Account::Avatar::AVATAR_LIMIT)),
|
||||
input_html: { accept: Account::Avatar::AVATAR_IMAGE_MIME_TYPES.join(',') },
|
||||
wrapper: :with_block_label
|
||||
|
||||
.fields-row__column.fields-row__column-6
|
||||
|
@ -57,8 +57,8 @@
|
|||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= f.input :header,
|
||||
hint: t('simple_form.hints.defaults.header', dimensions: Account::Header::HEADER_GEOMETRY, size: number_to_human_size(Account::Header::LIMIT)),
|
||||
input_html: { accept: Account::Header::IMAGE_MIME_TYPES.join(',') },
|
||||
hint: t('simple_form.hints.defaults.header', dimensions: Account::Header::HEADER_GEOMETRY, size: number_to_human_size(Account::Header::HEADER_LIMIT)),
|
||||
input_html: { accept: Account::Header::HEADER_IMAGE_MIME_TYPES.join(',') },
|
||||
wrapper: :with_block_label
|
||||
|
||||
.fields-row__column.fields-row__column-6
|
||||
|
|
|
@ -226,20 +226,26 @@ bg:
|
|||
approve_appeal_html: "%{name} одобри обжалването на решение за модериране от %{target}"
|
||||
approve_user_html: "%{name} одобри регистрирането от %{target}"
|
||||
assigned_to_self_report_html: "%{name} възложи на себе си доклад %{target}"
|
||||
change_email_user_html: "%{name} промени адреса на имейла на потребителя %{target}"
|
||||
change_role_user_html: "%{name} промени ролята на %{target}"
|
||||
confirm_user_html: "%{name} потвърди адреса на имейла на потребителя %{target}"
|
||||
create_account_warning_html: "%{name} изпрати предупреждение до %{target}"
|
||||
create_announcement_html: "%{name} създаде ново оповестяване %{target}"
|
||||
create_canonical_email_block_html: "%{name} блокира имейл с хеш %{target}"
|
||||
create_custom_emoji_html: "%{name} качи ново емоджи %{target}"
|
||||
create_domain_allow_html: "%{name} позволи федерирането с домейн %{target}"
|
||||
create_domain_block_html: "%{name} блокира домейн %{target}"
|
||||
create_email_domain_block_html: "%{name} блокира домейн за е-поща %{target}"
|
||||
create_ip_block_html: "%{name} създаде правило за IP %{target}"
|
||||
create_unavailable_domain_html: "%{name} спря доставянето до домейн %{target}"
|
||||
create_user_role_html: "%{name} създаде роля %{target}"
|
||||
demote_user_html: "%{name} понижи потребителя %{target}"
|
||||
destroy_announcement_html: "%{name} изтри оповестяване %{target}"
|
||||
destroy_canonical_email_block_html: "%{name} отблокира имейла с хеш %{target}"
|
||||
destroy_custom_emoji_html: "%{name} изтри емоджито %{target}"
|
||||
destroy_domain_allow_html: "%{name} забрани федерирация с домейн %{target}"
|
||||
destroy_domain_block_html: "%{name} отблокира домейн %{target}"
|
||||
destroy_email_domain_block_html: "%{name} отблокира домейн за е-поща %{target}"
|
||||
destroy_instance_html: "%{name} прочисти домейн %{target}"
|
||||
destroy_ip_block_html: "%{name} изтри правило за IP %{target}"
|
||||
destroy_status_html: "%{name} премахна публикация от %{target}"
|
||||
|
|
|
@ -1180,12 +1180,12 @@ de:
|
|||
use_security_key: Sicherheitsschlüssel verwenden
|
||||
author_attribution:
|
||||
example_title: Beispieltext
|
||||
hint_html: Schreibst du außerhalb von Mastodon Nachrichtenartikel oder betreibst du einen Blog? Bestimme, wie du Anerkennungen durch geteilte Links auf Mastodon handhaben möchtest.
|
||||
instructions: 'Der nachfolgende Code muss im HTML-Code deines Artikels sein:'
|
||||
hint_html: Schreibst du außerhalb von Mastodon journalistische Artikel oder andere Texte, beispielsweise in einem Blog? Lege hier fest, wann auf dein Profil verwiesen werden soll, wenn Links zu deinen Werken auf Mastodon geteilt werden.
|
||||
instructions: 'Der nachfolgende Code muss im HTML-Header deines zu verlinkenden Textes stehen:'
|
||||
more_from_html: Mehr von %{name}
|
||||
s_blog: Blog von %{name}
|
||||
then_instructions: Ergänze die Domain, auf der deine Inhalte veröffentlicht werden in das unten stehende Feld.
|
||||
title: Anerkennung als Autor*in
|
||||
then_instructions: Ergänze anschließend im unteren Feld die Domain, auf der sich deine Inhalte befinden.
|
||||
title: Verifizierung als Autor*in
|
||||
challenge:
|
||||
confirm: Fortfahren
|
||||
hint_html: "<strong>Hinweis:</strong> Wir werden dich für die nächste Stunde nicht erneut nach deinem Passwort fragen."
|
||||
|
@ -1625,7 +1625,7 @@ de:
|
|||
posting_defaults: Standardeinstellungen für Beiträge
|
||||
public_timelines: Öffentliche Timelines
|
||||
privacy:
|
||||
hint_html: "<strong>Bestimme, wie dein Profil und deine Beiträge gefunden werden sollen.</strong> Eine Vielzahl von Funktionen in Mastodon können dir helfen, eine größere Reichweite zu erlangen, wenn sie aktiviert sind. Nimm dir einen Moment Zeit, um diese Einstellungen zu überprüfen und sicherzustellen, dass sie für deinen Anwendungsfall geeignet sind."
|
||||
hint_html: "<strong>Bestimme selbst, wie dein Profil und deine Beiträge gefunden werden sollen.</strong> Zahlreiche Mastodon-Funktionen können dir für eine größere Reichweite behilflich sein. Nimm dir einen Moment Zeit, um diese Einstellungen zu überprüfen."
|
||||
privacy: Datenschutz
|
||||
privacy_hint_html: Bestimme, wie viele Informationen du für andere preisgeben möchtest. Viele Menschen entdecken interessante Profile und coole Apps, indem sie die Follower anderer Profile durchstöbern und die Apps sehen, über die Beiträge veröffentlicht wurden – möglicherweise möchtest du diese Informationen ausblenden.
|
||||
reach: Reichweite
|
||||
|
@ -1801,7 +1801,7 @@ de:
|
|||
enabled: Alte Beiträge automatisch entfernen
|
||||
enabled_hint: Löscht automatisch deine Beiträge, sobald sie die angegebene Altersgrenze erreicht haben, es sei denn, sie entsprechen einer der unten angegebenen Ausnahmen
|
||||
exceptions: Ausnahmen
|
||||
explanation: Damit Mastodon nicht durch das Löschen von Beiträgen ausgebremst wird, wartet der Server damit, bis wenig los ist. Aus diesem Grund werden deine Beiträge ggf. erst einige Zeit nach Erreichen der Altersgrenze gelöscht.
|
||||
explanation: Damit der Server nicht durch das Löschen von Beiträgen ausgebremst wird, wartet die Mastodon-Software, bis wenig(er) los ist. Deshalb könnten deine Beiträge ggf. erst einige Zeit nach Erreichen der Altersgrenze gelöscht werden.
|
||||
ignore_favs: Favoriten ignorieren
|
||||
ignore_reblogs: Geteilte Beiträge ignorieren
|
||||
interaction_exceptions: Ausnahmen basierend auf Interaktionen
|
||||
|
@ -1980,13 +1980,13 @@ de:
|
|||
seamless_external_login: Du bist über einen externen Dienst angemeldet, daher sind Passwort- und E-Mail-Einstellungen nicht verfügbar.
|
||||
signed_in_as: 'Angemeldet als:'
|
||||
verification:
|
||||
extra_instructions_html: <strong>Hinweis:</strong> Der Link auf deiner Website kann unsichtbar sein. Der wichtige Teil ist <code>rel="me"</code>, wodurch das Nachahmen von Personen auf Websites mit nutzergenerierten Inhalten verhindert wird. Du kannst auch ein <code>link</code>-Tag statt <code>a</code> im Header auf der Seite verwenden, jedoch muss der HTML-Code ohne das Ausführen von JavaScript zugänglich sein.
|
||||
extra_instructions_html: <strong>Hinweis:</strong> Der Link auf deiner Website kann unsichtbar sein. Der wichtige Teil ist <code>rel="me"</code>. Du kannst auch den Tag <code>link</code> im <code>head</code> (statt <code>a</code> im <code>body</code>) verwenden, jedoch muss die Internetseite ohne JavaScript abrufbar sein.
|
||||
here_is_how: So funktioniert’s
|
||||
hint_html: "<strong>Alle können ihre Identität auf Mastodon verifizieren.</strong> Basierend auf offenen Standards – jetzt und für immer kostenlos. Alles, was du brauchst, ist eine eigene Website. Wenn du von deinem Profil auf diese Website verlinkst, überprüfen wir, ob die Website zu deinem Profil zurückverlinkt, und zeigen einen visuellen Hinweis an."
|
||||
instructions_html: Kopiere den unten stehenden Code und füge ihn in den HTML-Code deiner Website ein. Trage anschließend die Adresse deiner Website in ein Zusatzfeld auf deinem Profil ein und speichere die Änderungen. Die Zusatzfelder befinden sich im Reiter „Profil bearbeiten“.
|
||||
verification: Verifizierung
|
||||
verified_links: Deine verifizierten Links
|
||||
website_verification: Website-Verifizierung
|
||||
verified_links: Deine verifizierten Domains
|
||||
website_verification: Verifizierung einer Website
|
||||
webauthn_credentials:
|
||||
add: Sicherheitsschlüssel hinzufügen
|
||||
create:
|
||||
|
|
|
@ -125,7 +125,7 @@ zh-CN:
|
|||
admin/reports: 举报管理
|
||||
all: 完全访问你的Mastodon账户
|
||||
blocks: 屏蔽
|
||||
bookmarks: 书签
|
||||
bookmarks: 收藏
|
||||
conversations: 会话
|
||||
crypto: 端到端加密
|
||||
favourites: 喜欢
|
||||
|
@ -172,7 +172,7 @@ zh-CN:
|
|||
read: 读取你的账户数据
|
||||
read:accounts: 查看账号信息
|
||||
read:blocks: 查看你的屏蔽列表
|
||||
read:bookmarks: 查看你的书签
|
||||
read:bookmarks: 查看你的收藏夹
|
||||
read:favourites: 查看喜欢的嘟文
|
||||
read:filters: 查看你的过滤规则
|
||||
read:follows: 查看你的关注
|
||||
|
@ -185,7 +185,7 @@ zh-CN:
|
|||
write: 修改你的账号数据
|
||||
write:accounts: 修改你的个人资料
|
||||
write:blocks: 屏蔽账号和域名
|
||||
write:bookmarks: 为嘟文添加书签
|
||||
write:bookmarks: 收藏嘟文
|
||||
write:conversations: 静音并删除会话
|
||||
write:favourites: 喜欢嘟文
|
||||
write:filters: 创建过滤规则
|
||||
|
|
|
@ -187,7 +187,7 @@ es-AR:
|
|||
create_domain_block: Crear bloqueo de dominio
|
||||
create_email_domain_block: Crear bloqueo de dominio de correo electrónico
|
||||
create_ip_block: Crear regla de dirección IP
|
||||
create_relay: Crear Relé
|
||||
create_relay: Crear relé
|
||||
create_unavailable_domain: Crear dominio no disponible
|
||||
create_user_role: Crear rol
|
||||
demote_user: Descender usuario
|
||||
|
@ -199,17 +199,17 @@ es-AR:
|
|||
destroy_email_domain_block: Eliminar bloqueo de dominio de correo electrónico
|
||||
destroy_instance: Purgar dominio
|
||||
destroy_ip_block: Eliminar regla de dirección IP
|
||||
destroy_relay: Eliminar Relé
|
||||
destroy_relay: Eliminar relé
|
||||
destroy_status: Eliminar mensaje
|
||||
destroy_unavailable_domain: Eliminar dominio no disponible
|
||||
destroy_user_role: Destruir rol
|
||||
disable_2fa_user: Deshabilitar 2FA
|
||||
disable_custom_emoji: Deshabilitar emoji personalizado
|
||||
disable_relay: Desactivar Relé
|
||||
disable_relay: Deshabilitar relé
|
||||
disable_sign_in_token_auth_user: Deshabilitar autenticación de token por correo electrónico para el usuario
|
||||
disable_user: Deshabilitar usuario
|
||||
enable_custom_emoji: Habilitar emoji personalizado
|
||||
enable_relay: Activar Relé
|
||||
enable_relay: Habilitar relé
|
||||
enable_sign_in_token_auth_user: Habilitar autenticación de token por correo electrónico para el usuario
|
||||
enable_user: Habilitar usuario
|
||||
memorialize_account: Convertir en cuenta conmemorativa
|
||||
|
@ -251,7 +251,7 @@ es-AR:
|
|||
create_domain_block_html: "%{name} bloqueó el dominio %{target}"
|
||||
create_email_domain_block_html: "%{name} bloqueó el dominio de correo electrónico %{target}"
|
||||
create_ip_block_html: "%{name} creó la regla para la dirección IP %{target}"
|
||||
create_relay_html: "%{name} creó un relé %{target}"
|
||||
create_relay_html: "%{name} creó el relé %{target}"
|
||||
create_unavailable_domain_html: "%{name} detuvo la entrega al dominio %{target}"
|
||||
create_user_role_html: "%{name} creó el rol %{target}"
|
||||
demote_user_html: "%{name} bajó de nivel al usuario %{target}"
|
||||
|
@ -269,11 +269,11 @@ es-AR:
|
|||
destroy_user_role_html: "%{name} eliminó el rol %{target}"
|
||||
disable_2fa_user_html: "%{name} deshabilitó el requerimiento de dos factores para el usuario %{target}"
|
||||
disable_custom_emoji_html: "%{name} deshabilitó el emoji %{target}"
|
||||
disable_relay_html: "%{name} desactivó el relé %{target}"
|
||||
disable_relay_html: "%{name} deshabilitó el relé %{target}"
|
||||
disable_sign_in_token_auth_user_html: "%{name} deshabilitó la autenticación de token por correo electrónico para %{target}"
|
||||
disable_user_html: "%{name} deshabilitó el inicio de sesión para el usuario %{target}"
|
||||
enable_custom_emoji_html: "%{name} habilitó el emoji %{target}"
|
||||
enable_relay_html: "%{name} activó el relé %{target}"
|
||||
enable_relay_html: "%{name} eliminó el relé %{target}"
|
||||
enable_sign_in_token_auth_user_html: "%{name} habilitó la autenticación de token por correo electrónico para %{target}"
|
||||
enable_user_html: "%{name} habilitó el inicio de sesión para el usuario %{target}"
|
||||
memorialize_account_html: "%{name} convirtió la cuenta de %{target} en una cuenta conmemorativa"
|
||||
|
|
|
@ -144,7 +144,7 @@ de:
|
|||
url: Wohin Ereignisse gesendet werden
|
||||
labels:
|
||||
account:
|
||||
attribution_domains_as_text: Websites, die dich anerkennen dürfen
|
||||
attribution_domains_as_text: Websites, die auf dich verweisen dürfen
|
||||
discoverable: Profil und Beiträge in Suchalgorithmen berücksichtigen
|
||||
fields:
|
||||
name: Beschriftung
|
||||
|
|
|
@ -1272,7 +1272,7 @@ zh-CN:
|
|||
request: 请求你的存档
|
||||
size: 大小
|
||||
blocks: 屏蔽的用户
|
||||
bookmarks: 书签
|
||||
bookmarks: 收藏
|
||||
csv: CSV
|
||||
domain_blocks: 域名屏蔽
|
||||
lists: 列表
|
||||
|
@ -1361,7 +1361,7 @@ zh-CN:
|
|||
blocking_html:
|
||||
other: 你即将使用来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 个</strong>账户<strong>替换你的屏蔽列表</strong>。
|
||||
bookmarks_html:
|
||||
other: 你即将使用来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 条</strong>嘟文<strong>替换你的书签</strong>。
|
||||
other: 你即将使用来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 条</strong>嘟文<strong>替换你的收藏列表</strong>。
|
||||
domain_blocking_html:
|
||||
other: 你即将使用来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 个</strong>域名<strong>替换你的域名屏蔽列表</strong>。
|
||||
following_html:
|
||||
|
@ -1374,7 +1374,7 @@ zh-CN:
|
|||
blocking_html:
|
||||
other: 你即将<strong>屏蔽</strong>来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 个账号</strong>。
|
||||
bookmarks_html:
|
||||
other: 你即将把来自<strong> %{filename} </strong>的<strong> %{count} 篇嘟文</strong>添加到你的<strong>书签</strong>中。
|
||||
other: 你即将把来自<strong> %{filename} </strong>的<strong> %{count} 篇嘟文</strong>添加到你的<strong>收藏夹</strong>中。
|
||||
domain_blocking_html:
|
||||
other: 你即将<strong>屏蔽</strong>来自 <strong>%{filename}</strong> 的最多 <strong>%{count} 个域名</strong>。
|
||||
following_html:
|
||||
|
@ -1395,18 +1395,18 @@ zh-CN:
|
|||
time_started: 开始于
|
||||
titles:
|
||||
blocking: 正在导入被屏蔽的账户
|
||||
bookmarks: 正在导入书签
|
||||
bookmarks: 正在导入收藏
|
||||
domain_blocking: 正在导入被屏蔽的域名
|
||||
following: 正在导入关注的账户
|
||||
lists: 导入列表
|
||||
muting: 正在导入隐藏的账户
|
||||
type: 导入类型
|
||||
type_groups:
|
||||
constructive: 关注和书签
|
||||
constructive: 关注与收藏
|
||||
destructive: 屏蔽与隐藏
|
||||
types:
|
||||
blocking: 屏蔽列表
|
||||
bookmarks: 书签
|
||||
bookmarks: 收藏
|
||||
domain_blocking: 域名屏蔽列表
|
||||
following: 关注列表
|
||||
lists: 列表
|
||||
|
@ -1757,24 +1757,24 @@ zh-CN:
|
|||
unlisted_long: 对所有人可见,但不出现在公共时间线上
|
||||
statuses_cleanup:
|
||||
enabled: 自动删除旧嘟文
|
||||
enabled_hint: 达到指定过期时间后自动删除你的嘟文,除非满足下列条件之一
|
||||
enabled_hint: 自动删除你发布的超过指定期限的嘟文,除非满足下列条件之一
|
||||
exceptions: 例外
|
||||
explanation: 删除嘟文是一个消耗系统资源的耗时操作,所以这个操作会在服务器空闲时完成。因此,你的嘟文可能会在达到过期阈值之后一段时间才会被删除。
|
||||
ignore_favs: 取消喜欢
|
||||
ignore_reblogs: 忽略转嘟
|
||||
explanation: 删除嘟文会占用大量服务器资源,所以这个操作将在服务器空闲时完成。因此,你的嘟文可能会在达到删除期限之后一段时间才会被删除。
|
||||
ignore_favs: 喜欢数阈值
|
||||
ignore_reblogs: 转嘟数阈值
|
||||
interaction_exceptions: 基于互动的例外
|
||||
interaction_exceptions_explanation: 请注意,如果嘟文超出转嘟和喜欢的阈值之后,又降到阈值以下,则可能不会被删除。
|
||||
interaction_exceptions_explanation: 请注意,如果嘟文的转嘟数和喜欢数超过保留阈值之后,又降到阈值以下,则可能不会被删除。
|
||||
keep_direct: 保留私信
|
||||
keep_direct_hint: 不会删除你的任何私信
|
||||
keep_direct_hint: 不删除你的任何私信
|
||||
keep_media: 保留带媒体附件的嘟文
|
||||
keep_media_hint: 不会删除任何包含媒体附件的嘟文
|
||||
keep_media_hint: 不删除任何包含媒体附件的嘟文
|
||||
keep_pinned: 保留置顶嘟文
|
||||
keep_pinned_hint: 不会删除你的任何置顶嘟文
|
||||
keep_pinned_hint: 不删除你的任何置顶嘟文
|
||||
keep_polls: 保留投票
|
||||
keep_polls_hint: 不会删除你的任何投票
|
||||
keep_self_bookmark: 保存被你加入书签的嘟文
|
||||
keep_self_bookmark_hint: 如果你已将自己的嘟文添加书签,就不会删除这些嘟文
|
||||
keep_self_fav: 保留你点赞的嘟文
|
||||
keep_polls_hint: 不删除你的任何投票
|
||||
keep_self_bookmark: 保存你收藏的的嘟文
|
||||
keep_self_bookmark_hint: 不删除你收藏的嘟文
|
||||
keep_self_fav: 保留你喜欢的嘟文
|
||||
keep_self_fav_hint: 如果你喜欢了自己的嘟文,则不会删除这些嘟文
|
||||
min_age:
|
||||
'1209600': 2周
|
||||
|
@ -1786,8 +1786,8 @@ zh-CN:
|
|||
'63113904': 两年
|
||||
'7889238': 3个月
|
||||
min_age_label: 过期阈值
|
||||
min_favs: 保留如下嘟文:点赞数超过
|
||||
min_favs_hint: 点赞数超过该阈值的的嘟文都不会被删除。如果留空,则无论嘟文获得多少点赞,都将被删除。
|
||||
min_favs: 保留如下嘟文:喜欢数超过
|
||||
min_favs_hint: 获得喜欢数超过该阈值的的嘟文都不会被删除。如果留空,则无论嘟文获得多少点赞,都将被删除。
|
||||
min_reblogs: 保留如下嘟文:转嘟数超过
|
||||
min_reblogs_hint: 转嘟数超过该阈值的的嘟文不会被删除。如果留空,则无论嘟文获得多少转嘟,都将被删除。
|
||||
stream_entries:
|
||||
|
@ -1899,13 +1899,13 @@ zh-CN:
|
|||
edit_profile_step: 完善个人资料,提升你的互动体验。
|
||||
edit_profile_title: 个性化你的个人资料
|
||||
explanation: 下面是几个小贴士,希望它们能帮到你
|
||||
feature_action: 了解更多
|
||||
feature_action: 详细了解
|
||||
feature_audience: Mastodon 为你提供了无需中间商即可管理受众的独特可能。Mastodon 可被部署在你自己的基础设施上,允许你关注其它任何 Mastodon 在线服务器的用户,或被任何其他在线 Mastodon 服务器的用户关注,并且不受你之外的任何人控制。
|
||||
feature_audience_title: 自由吸引你的受众
|
||||
feature_control: 你最清楚你想在你自己的主页中看到什么动态。没有算法或广告浪费你的时间。你可以用一个账号关注任何 Mastodon 服务器上的任何人,并按时间顺序获得他们发布的嘟文,让你的互联网的角落更合自己的心意。
|
||||
feature_control_title: 掌控自己的时间线
|
||||
feature_creativity: Mastodon 支持音频、视频和图片、无障碍描述、投票、内容警告, 动画头像、自定义表情包、缩略图裁剪控制等功能,帮助你在网上尽情表达自己。无论你是要发布你的艺术作品、音乐还是播客,Mastodon 都能为你服务。
|
||||
feature_creativity_title: 无与伦比的创造力
|
||||
feature_creativity_title: 尽情发挥创造力
|
||||
feature_moderation: Mastodon 将决策权交还给你。每个服务器都会创建自己的规则和条例,并在站点内施行,而不是像企业社交媒体那样居高临下,这使得它可以最灵活地响应不同人群的需求。加入一个你认同其规则的服务器,或托管你自己的服务器。
|
||||
feature_moderation_title: 管理,本应如此
|
||||
follow_action: 关注
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@mastodon/mastodon",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "yarn@4.5.2",
|
||||
"packageManager": "yarn@4.5.3",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
@ -47,7 +47,7 @@
|
|||
"@babel/preset-typescript": "^7.21.5",
|
||||
"@babel/runtime": "^7.23.7",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@formatjs/intl-pluralrules": "^5.2.2",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
|
|
|
@ -5,12 +5,20 @@ require 'rails_helper'
|
|||
RSpec.describe ApplicationHelper do
|
||||
describe 'body_classes' do
|
||||
context 'with a body class string from a controller' do
|
||||
before { helper.extend controller_helpers }
|
||||
before do
|
||||
user = Fabricate :user
|
||||
user.settings['web.use_system_font'] = true
|
||||
user.settings['web.reduce_motion'] = true
|
||||
user.save
|
||||
|
||||
it 'uses the controller body classes in the result' do
|
||||
helper.extend controller_helpers
|
||||
end
|
||||
|
||||
it 'uses the current theme and user settings classes in the result' do
|
||||
expect(helper.body_classes)
|
||||
.to match(/modal-layout compose-standalone/)
|
||||
.and match(/theme-default/)
|
||||
.to match(/theme-default/)
|
||||
.and match(/system-font/)
|
||||
.and match(/reduce-motion/)
|
||||
end
|
||||
|
||||
it 'includes values set via content_for' do
|
||||
|
@ -24,10 +32,8 @@ RSpec.describe ApplicationHelper do
|
|||
|
||||
def controller_helpers
|
||||
Module.new do
|
||||
def body_class_string = 'modal-layout compose-standalone'
|
||||
|
||||
def current_account
|
||||
@current_account ||= Fabricate(:account)
|
||||
@current_account ||= Fabricate(:account, user: User.last)
|
||||
end
|
||||
|
||||
def current_theme = 'default'
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@mastodon/streaming",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "yarn@4.5.2",
|
||||
"packageManager": "yarn@4.5.3",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
54
yarn.lock
54
yarn.lock
|
@ -1837,7 +1837,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/postcss-random-function@npm:^1.0.0":
|
||||
"@csstools/postcss-random-function@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@csstools/postcss-random-function@npm:1.0.1"
|
||||
dependencies:
|
||||
|
@ -1876,16 +1876,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/postcss-sign-functions@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@csstools/postcss-sign-functions@npm:1.0.0"
|
||||
"@csstools/postcss-sign-functions@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@csstools/postcss-sign-functions@npm:1.1.0"
|
||||
dependencies:
|
||||
"@csstools/css-calc": "npm:^2.1.0"
|
||||
"@csstools/css-parser-algorithms": "npm:^3.0.4"
|
||||
"@csstools/css-tokenizer": "npm:^3.0.3"
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
checksum: 10c0/ec745b2f1e714ffead43ade5964234dfc1750c3a71d2e29df862ab3f79ba4a1275187b270b4c226bbb1155bee8e9e63c35597b4f4cb3effaa632e5e07e422344
|
||||
checksum: 10c0/503bbaa8fe1d1a619880d5d6b838f07f1898a5820889e5db3c4e02bb8b340dab18b88f439f9f1da44c6669bab2d4ba3f9543643ccc459d8a21191c5d22109c9b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -1979,41 +1979,41 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/accessibility@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "@dnd-kit/accessibility@npm:3.1.0"
|
||||
"@dnd-kit/accessibility@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "@dnd-kit/accessibility@npm:3.1.1"
|
||||
dependencies:
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/4f9d24e801d66d4fbb551ec389ed90424dd4c5bbdf527000a618e9abb9833cbd84d9a79e362f470ccbccfbd6d00217a9212c92f3cef66e01c951c7f79625b9d7
|
||||
checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/core@npm:^6.1.0":
|
||||
version: 6.1.0
|
||||
resolution: "@dnd-kit/core@npm:6.1.0"
|
||||
version: 6.2.0
|
||||
resolution: "@dnd-kit/core@npm:6.2.0"
|
||||
dependencies:
|
||||
"@dnd-kit/accessibility": "npm:^3.1.0"
|
||||
"@dnd-kit/accessibility": "npm:^3.1.1"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 10c0/c793eb97cb59285ca8937ebcdfcd27cff09d750ae06722e36ca5ed07925e41abc36a38cff98f9f6056f7a07810878d76909826142a2968330e7e22060e6be584
|
||||
checksum: 10c0/478d6bb027441b0e5fa5ecd9a4da8a5876811505147303de1a5a0783a4418c5f7f464bd3eb07b4be77ef7626364d1b905dc2a4f9055093b845cf39e1d6f13b73
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/sortable@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "@dnd-kit/sortable@npm:8.0.0"
|
||||
"@dnd-kit/sortable@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "@dnd-kit/sortable@npm:9.0.0"
|
||||
dependencies:
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
"@dnd-kit/core": ^6.1.0
|
||||
"@dnd-kit/core": ^6.2.0
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/a6066c652b892c6a11320c7d8f5c18fdf723e721e8eea37f4ab657dee1ac5e7ca710ac32ce0712a57fe968bc07c13bcea5d5599d90dfdd95619e162befd4d2fb
|
||||
checksum: 10c0/30566ec05371bd59729c0fb87537d78cd1760f08e4b49b5fa8298ebd3cb9f29fc258a48425c6a060b9efeca88e36a059000e770d630681986626abcc3589e97a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -2861,7 +2861,7 @@ __metadata:
|
|||
"@babel/preset-typescript": "npm:^7.21.5"
|
||||
"@babel/runtime": "npm:^7.23.7"
|
||||
"@dnd-kit/core": "npm:^6.1.0"
|
||||
"@dnd-kit/sortable": "npm:^8.0.0"
|
||||
"@dnd-kit/sortable": "npm:^9.0.0"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
"@formatjs/cli": "npm:^6.1.1"
|
||||
"@formatjs/intl-pluralrules": "npm:^5.2.2"
|
||||
|
@ -5175,13 +5175,13 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"axios@npm:^1.4.0":
|
||||
version: 1.7.7
|
||||
resolution: "axios@npm:1.7.7"
|
||||
version: 1.7.8
|
||||
resolution: "axios@npm:1.7.8"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7
|
||||
checksum: 10c0/23ae2d0105aea9170c34ac9b6f30d9b2ab2fa8b1370205d2f7ce98b9f9510ab420148c13359ee837ea5a4bf2fb028ff225bd2fc92052fb0c478c6b4a836e2d5f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -13878,8 +13878,8 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"postcss-preset-env@npm:^10.0.0":
|
||||
version: 10.1.0
|
||||
resolution: "postcss-preset-env@npm:10.1.0"
|
||||
version: 10.1.1
|
||||
resolution: "postcss-preset-env@npm:10.1.1"
|
||||
dependencies:
|
||||
"@csstools/postcss-cascade-layers": "npm:^5.0.1"
|
||||
"@csstools/postcss-color-function": "npm:^4.0.6"
|
||||
|
@ -13905,10 +13905,10 @@ __metadata:
|
|||
"@csstools/postcss-normalize-display-values": "npm:^4.0.0"
|
||||
"@csstools/postcss-oklab-function": "npm:^4.0.6"
|
||||
"@csstools/postcss-progressive-custom-properties": "npm:^4.0.0"
|
||||
"@csstools/postcss-random-function": "npm:^1.0.0"
|
||||
"@csstools/postcss-random-function": "npm:^1.0.1"
|
||||
"@csstools/postcss-relative-color-syntax": "npm:^3.0.6"
|
||||
"@csstools/postcss-scope-pseudo-class": "npm:^4.0.1"
|
||||
"@csstools/postcss-sign-functions": "npm:^1.0.0"
|
||||
"@csstools/postcss-sign-functions": "npm:^1.1.0"
|
||||
"@csstools/postcss-stepped-value-functions": "npm:^4.0.5"
|
||||
"@csstools/postcss-text-decoration-shorthand": "npm:^4.0.1"
|
||||
"@csstools/postcss-trigonometric-functions": "npm:^4.0.5"
|
||||
|
@ -13946,7 +13946,7 @@ __metadata:
|
|||
postcss-selector-not: "npm:^8.0.1"
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
checksum: 10c0/bd157dbed38c3c125b3bf86f5437a8094539ec5bf24428487c7bbf29da393731e48053afc695494cc9dbe4d182cfe405c398fcf0b22eb326b6db395e7315f892
|
||||
checksum: 10c0/99931117735a66827c7318be023ddb614990457617ccbe7fd2fdc1f10345554652df180d4842768d68d57e14fc0be4d86d0b413c65e77e02db5511e57ed07c4f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue