Merge pull request #2903 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to d0753ec276
pull/2911/head
Claire 2024-11-25 08:39:09 +01:00 committed by GitHub
commit 3ca5d844ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
215 changed files with 3547 additions and 4017 deletions

View File

@ -95,7 +95,7 @@ GEM
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.1009.0)
aws-partitions (1.1012.0)
aws-sdk-core (3.213.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -104,7 +104,7 @@ GEM
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.172.0)
aws-sdk-s3 (1.173.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@ -200,7 +200,7 @@ GEM
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
domain_name (0.6.20240107)
doorkeeper (5.7.1)
doorkeeper (5.8.0)
railties (>= 5)
dotenv (3.1.4)
drb (2.2.1)

View File

@ -74,19 +74,6 @@ export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST';
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS';
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL';
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR';
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE';
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export * from './accounts_typed';
@ -686,38 +673,6 @@ export function unpinAccountFail(error) {
};
}
export function fetchPinnedAccounts() {
return (dispatch) => {
dispatch(fetchPinnedAccountsRequest());
api().get('/api/v1/endorsements', { params: { limit: 0 } }).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchPinnedAccountsSuccess(response.data));
}).catch(err => dispatch(fetchPinnedAccountsFail(err)));
};
}
export function fetchPinnedAccountsRequest() {
return {
type: PINNED_ACCOUNTS_FETCH_REQUEST,
};
}
export function fetchPinnedAccountsSuccess(accounts, next) {
return {
type: PINNED_ACCOUNTS_FETCH_SUCCESS,
accounts,
next,
};
}
export function fetchPinnedAccountsFail(error) {
return {
type: PINNED_ACCOUNTS_FETCH_FAIL,
error,
};
}
export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch) => {
const data = new FormData();
@ -742,62 +697,3 @@ export const navigateToProfile = (accountId) => {
}
};
};
export function fetchPinnedAccountsSuggestions(q) {
return (dispatch) => {
dispatch(fetchPinnedAccountsSuggestionsRequest());
const params = {
q,
resolve: false,
limit: 4,
following: true,
};
api().get('/api/v1/accounts/search', { params }).then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchPinnedAccountsSuggestionsSuccess(q, response.data));
}).catch(err => dispatch(fetchPinnedAccountsSuggestionsFail(err)));
};
}
export function fetchPinnedAccountsSuggestionsRequest() {
return {
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST,
};
}
export function fetchPinnedAccountsSuggestionsSuccess(query, accounts) {
return {
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS,
query,
accounts,
};
}
export function fetchPinnedAccountsSuggestionsFail(error) {
return {
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL,
error,
};
}
export function clearPinnedAccountsSuggestions() {
return {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
};
}
export function changePinnedAccountsSuggestions(value) {
return {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
value,
};
}
export function resetPinnedAccountsEditor() {
return {
type: PINNED_ACCOUNTS_EDITOR_RESET,
};
}

View File

@ -1,8 +1,5 @@
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
@ -11,45 +8,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
export const fetchList = id => (dispatch, getState) => {
if (getState().getIn(['lists', id])) {
return;
@ -100,89 +62,6 @@ export const fetchListsFail = error => ({
error,
});
export const submitListEditor = shouldReset => (dispatch, getState) => {
const listId = getState().getIn(['listEditor', 'listId']);
const title = getState().getIn(['listEditor', 'title']);
if (listId === null) {
dispatch(createList(title, shouldReset));
} else {
dispatch(updateList(listId, title, shouldReset));
}
};
export const setupListEditor = listId => (dispatch, getState) => {
dispatch({
type: LIST_EDITOR_SETUP,
list: getState().getIn(['lists', listId]),
});
dispatch(fetchListAccounts(listId));
};
export const changeListEditorTitle = value => ({
type: LIST_EDITOR_TITLE_CHANGE,
value,
});
export const createList = (title, shouldReset) => (dispatch) => {
dispatch(createListRequest());
api().post('/api/v1/lists', { title }).then(({ data }) => {
dispatch(createListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(createListFail(err)));
};
export const createListRequest = () => ({
type: LIST_CREATE_REQUEST,
});
export const createListSuccess = list => ({
type: LIST_CREATE_SUCCESS,
list,
});
export const createListFail = error => ({
type: LIST_CREATE_FAIL,
error,
});
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => {
dispatch(updateListRequest(id));
api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(updateListFail(id, err)));
};
export const updateListRequest = id => ({
type: LIST_UPDATE_REQUEST,
id,
});
export const updateListSuccess = list => ({
type: LIST_UPDATE_SUCCESS,
list,
});
export const updateListFail = (id, error) => ({
type: LIST_UPDATE_FAIL,
id,
error,
});
export const resetListEditor = () => ({
type: LIST_EDITOR_RESET,
});
export const deleteList = id => (dispatch) => {
dispatch(deleteListRequest(id));
@ -206,167 +85,3 @@ export const deleteListFail = (id, error) => ({
id,
error,
});
export const fetchListAccounts = listId => (dispatch) => {
dispatch(fetchListAccountsRequest(listId));
api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListAccountsSuccess(listId, data));
}).catch(err => dispatch(fetchListAccountsFail(listId, err)));
};
export const fetchListAccountsRequest = id => ({
type: LIST_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchListAccountsSuccess = (id, accounts, next) => ({
type: LIST_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchListAccountsFail = (id, error) => ({
type: LIST_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchListSuggestions = q => (dispatch) => {
const params = {
q,
resolve: false,
limit: 4,
following: true,
};
api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchListSuggestionsReady = (query, accounts) => ({
type: LIST_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearListSuggestions = () => ({
type: LIST_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeListSuggestions = value => ({
type: LIST_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToListEditor = accountId => (dispatch, getState) => {
dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const addToList = (listId, accountId) => (dispatch) => {
dispatch(addToListRequest(listId, accountId));
api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addToListSuccess(listId, accountId)))
.catch(err => dispatch(addToListFail(listId, accountId, err)));
};
export const addToListRequest = (listId, accountId) => ({
type: LIST_EDITOR_ADD_REQUEST,
listId,
accountId,
});
export const addToListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_ADD_SUCCESS,
listId,
accountId,
});
export const addToListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_ADD_FAIL,
listId,
accountId,
error,
});
export const removeFromListEditor = accountId => (dispatch, getState) => {
dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const removeFromList = (listId, accountId) => (dispatch) => {
dispatch(removeFromListRequest(listId, accountId));
api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeFromListSuccess(listId, accountId)))
.catch(err => dispatch(removeFromListFail(listId, accountId, err)));
};
export const removeFromListRequest = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_REQUEST,
listId,
accountId,
});
export const removeFromListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_SUCCESS,
listId,
accountId,
});
export const removeFromListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_REMOVE_FAIL,
listId,
accountId,
error,
});
export const resetListAdder = () => ({
type: LIST_ADDER_RESET,
});
export const setupListAdder = accountId => (dispatch, getState) => {
dispatch({
type: LIST_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchLists());
dispatch(fetchAccountLists(accountId));
};
export const fetchAccountLists = accountId => (dispatch) => {
dispatch(fetchAccountListsRequest(accountId));
api().get(`/api/v1/accounts/${accountId}/lists`)
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountListsFail(accountId, err)));
};
export const fetchAccountListsRequest = id => ({
type:LIST_ADDER_LISTS_FETCH_REQUEST,
id,
});
export const fetchAccountListsSuccess = (id, lists) => ({
type: LIST_ADDER_LISTS_FETCH_SUCCESS,
id,
lists,
});
export const fetchAccountListsFail = (id, err) => ({
type: LIST_ADDER_LISTS_FETCH_FAIL,
id,
err,
});
export const addToListAdder = listId => (dispatch, getState) => {
dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
};
export const removeFromListAdder = listId => (dispatch, getState) => {
dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
};

View File

@ -0,0 +1,13 @@
import { apiCreate, apiUpdate } from 'flavours/glitch/api/lists';
import type { List } from 'flavours/glitch/models/list';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
export const createList = createDataLoadingThunk(
'list/create',
(list: Partial<List>) => apiCreate(list),
);
export const updateList = createDataLoadingThunk(
'list/update',
(list: Partial<List>) => apiUpdate(list),
);

View File

@ -68,6 +68,7 @@ export async function apiRequest<ApiResponse = unknown>(
method: Method,
url: string,
args: {
signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
timeout?: number;

View File

@ -0,0 +1,32 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'flavours/glitch/api';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
export const apiCreate = (list: Partial<ApiListJSON>) =>
apiRequestPost<ApiListJSON>('v1/lists', list);
export const apiUpdate = (list: Partial<ApiListJSON>) =>
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
export const apiGetAccounts = (listId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
limit: 0,
});
export const apiGetAccountLists = (accountId: string) =>
apiRequestGet<ApiListJSON[]>(`v1/accounts/${accountId}/lists`);
export const apiAddAccountToList = (listId: string, accountId: string) =>
apiRequestPost(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});
export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});

View File

@ -0,0 +1,10 @@
// See app/serializers/rest/list_serializer.rb
export type RepliesPolicyType = 'list' | 'followed' | 'none';
export interface ApiListJSON {
id: string;
title: string;
exclusive: boolean;
replies_policy: RepliesPolicyType;
}

View File

@ -7,11 +7,11 @@ import { Icon } from './icon';
interface Props {
value: string;
checked: boolean;
indeterminate: boolean;
name: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
label: React.ReactNode;
checked?: boolean;
indeterminate?: boolean;
name?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
label?: React.ReactNode;
}
export const CheckBox: React.FC<Props> = ({
@ -30,6 +30,7 @@ export const CheckBox: React.FC<Props> = ({
value={value}
checked={checked}
onChange={onChange}
readOnly={!onChange}
/>
<span
@ -42,7 +43,7 @@ export const CheckBox: React.FC<Props> = ({
)}
</span>
<span>{label}</span>
{label && <span>{label}</span>}
</label>
);
};

View File

@ -80,6 +80,7 @@ class ScrollableList extends PureComponent {
children: PropTypes.node,
bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool,
footer: PropTypes.node,
};
static defaultProps = {
@ -324,7 +325,7 @@ class ScrollableList extends PureComponent {
};
render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = Children.count(children);
@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
<div className='scrollable__append'>
<LoadingIndicator />
</div>
{footer}
</div>
);
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
scrollableArea = (
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend}
@ -375,6 +378,8 @@ class ScrollableList extends PureComponent {
{!hasMore && append}
</div>
{footer}
</div>
);
} else {
@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
<div className='empty-column-indicator'>
{emptyMessage}
</div>
{footer}
</div>
);
}

View File

@ -13,6 +13,13 @@ const mapStateToProps = state => ({
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
dispatch(isUserTouching() ? openModal({

View File

@ -8,11 +8,9 @@ import { connect } from 'react-redux';
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import PersonCheckIcon from '@/material-icons/400-24px/person_check.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import Column from 'flavours/glitch/features/ui/components/column';
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
@ -27,7 +25,6 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
});
class GettingStartedMisc extends ImmutablePureComponent {
@ -38,12 +35,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
};
openFeaturedAccountsModal = () => {
this.props.dispatch(openModal({
modalType: 'PINNED_ACCOUNTS_EDITOR',
}));
};
render () {
const { intl } = this.props;
const { signedIn } = this.props.identity;
@ -54,7 +45,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
{signedIn && (<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />)}
{signedIn && (<ColumnLink key='pinned' icon='thumb-tack' iconComponent={PushPinIcon} text={intl.formatMessage(messages.pins)} to='/pinned' />)}
{signedIn && (<ColumnLink key='featured_users' icon='users' iconComponent={PersonCheckIcon} text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />)}
{signedIn && (<ColumnLink key='mutes' icon='volume-off' iconComponent={VolumeOffIcon} text={intl.formatMessage(messages.mutes)} to='/mutes' />)}
{signedIn && (<ColumnLink key='blocks' icon='ban' iconComponent={BlockIcon} text={intl.formatMessage(messages.blocks)} to='/blocks' />)}
{signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' iconComponent={BlockIcon} text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)}

View File

@ -1,43 +0,0 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View File

@ -1,75 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const MapStateToProps = (state, { listId, added }) => ({
list: state.get('lists').get(listId),
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
});
const mapDispatchToProps = (dispatch, { listId }) => ({
onRemove: () => dispatch(removeFromListAdder(listId)),
onAdd: () => dispatch(addToListAdder(listId)),
});
class List extends ImmutablePureComponent {
static propTypes = {
list: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { list, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
{list.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));

View File

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupListAdder, resetListAdder } from '../../actions/lists';
import NewListForm from '../lists/components/new_list_form';
import Account from './components/account';
import List from './components/list';
// hack
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
listIds: getOrderedLists(state).map(list=>list.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupListAdder(accountId)),
onReset: () => dispatch(resetListAdder()),
});
class ListAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
listIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, listIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewListForm />
<div className='list-adder__lists'>
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder));

View File

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'flavours/glitch/actions/lists';
import { createList } from 'flavours/glitch/actions/lists_typed';
import {
apiGetAccountLists,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'flavours/glitch/api/lists';
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
import { Button } from 'flavours/glitch/components/button';
import { CheckBox } from 'flavours/glitch/components/check_box';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
newList: {
id: 'lists.new_list_name',
defaultMessage: 'New list name',
},
createList: {
id: 'lists.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const ListItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewListItem: React.FC<{
onCreate: (list: ApiListJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createList({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newList)}
/>
</label>
<Button text={intl.formatMessage(messages.createList)} type='submit' />
</form>
);
};
const ListAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const lists = useAppSelector((state) => getOrderedLists(state));
const [listIds, setListIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchLists());
apiGetAccountLists(accountId)
.then((data) => {
setListIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setListIds, accountId]);
const handleToggle = useCallback(
(listId: string, checked: boolean) => {
if (checked) {
setListIds((currentListIds) => [listId, ...currentListIds]);
apiAddAccountToList(listId, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
});
} else {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
apiRemoveAccountFromList(listId, accountId).catch(() => {
setListIds((currentListIds) => [listId, ...currentListIds]);
});
}
},
[setListIds, accountId],
);
const handleCreate = useCallback(
(list: ApiListJSON) => {
setListIds((currentListIds) => [list.id, ...currentListIds]);
apiAddAccountToList(list.id, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== list.id),
);
});
},
[setListIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='lists.add_to_lists'
defaultMessage='Add {name} to lists'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewListItem onCreate={handleCreate} />
{lists.map((list) => (
<ListItem
key={list.id}
id={list.id}
title={list.title}
checked={listIds.includes(list.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default ListAdder;

View File

@ -1,82 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View File

@ -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 { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(false)),
});
class ListForm 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(ListForm));

View File

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View File

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditListForm from './components/edit_list_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: listId => dispatch(setupListEditor(listId)),
onClear: () => dispatch(clearListSuggestions()),
onReset: () => dispatch(resetListEditor()),
});
class ListEditor extends ImmutablePureComponent {
static propTypes = {
listId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, listId } = this.props;
onInitialize(listId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditListForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));

View File

@ -1,21 +1,19 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { fetchList, updateList } from 'flavours/glitch/actions/lists';
import { fetchList } from 'flavours/glitch/actions/lists';
import { openModal } from 'flavours/glitch/actions/modal';
import { connectListStream } from 'flavours/glitch/actions/streaming';
import { expandListTimeline } from 'flavours/glitch/actions/timelines';
@ -23,17 +21,10 @@ import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { RadioButton } from 'flavours/glitch/components/radio_button';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
const messages = defineMessages({
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
});
const mapStateToProps = (state, props) => ({
list: state.getIn(['lists', props.params.id]),
hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
@ -115,13 +106,6 @@ class ListTimeline extends PureComponent {
this.props.dispatch(expandListTimeline(id, { maxId }));
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'LIST_EDITOR',
modalProps: { listId: this.props.params.id },
}));
};
handleDeleteClick = () => {
const { dispatch, columnId } = this.props;
const { id } = this.props.params;
@ -129,25 +113,11 @@ class ListTimeline extends PureComponent {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
};
handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined));
};
render () {
const { hasUnread, columnId, multiColumn, list, intl } = this.props;
const { hasUnread, columnId, multiColumn, list } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
if (typeof list === 'undefined') {
return (
@ -178,35 +148,14 @@ class ListTimeline extends PureComponent {
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Link to={`/lists/${id}/edit`} className='text-btn column-header__setting-btn'>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
</Link>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button>
</section>
<section>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>
</div>
</section>
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</section>
)}
</div>
</ColumnHeader>
@ -229,4 +178,4 @@ class ListTimeline extends PureComponent {
}
export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline)));
export default withRouter(connect(mapStateToProps)(ListTimeline));

View File

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists';
import { Button } from 'flavours/glitch/components/button';
const messages = defineMessages({
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
title: { id: 'lists.new.create', defaultMessage: 'Add list' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: state.getIn(['listEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(true)),
});
class NewListForm 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 label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));

View File

@ -1,91 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'flavours/glitch/actions/lists';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import Column from 'flavours/glitch/features/ui/components/column';
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
import NewListForm from './components/new_list_form';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
});
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
class Lists extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchLists());
}
render () {
const { intl, lists, multiColumn } = this.props;
if (!lists) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} icon='bars' iconComponent={ListAltIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<NewListForm />
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{lists.map(list =>
<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Lists));

View File

@ -0,0 +1,145 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'flavours/glitch/actions/lists';
import { openModal } from 'flavours/glitch/actions/modal';
import Column from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
create: { id: 'lists.create_list', defaultMessage: 'Create list' },
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const ListItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='lists'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Lists: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='lists.no_lists_yet'
defaultMessage='No lists yet.'
/>
<br />
<FormattedMessage
id='lists.create_a_list_to_organize'
defaultMessage='Create a new list to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/lists/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Lists;

View File

@ -0,0 +1,373 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
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 'flavours/glitch/actions/accounts';
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
import { fetchList } from 'flavours/glitch/actions/lists';
import { apiRequest } from 'flavours/glitch/api';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'flavours/glitch/api/lists';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import Column from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { FollowersCounter } from 'flavours/glitch/components/counters';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
import { me } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
placeholder: {
id: 'lists.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
add: { id: 'lists.add_member', defaultMessage: 'Add' },
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
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;
partOfList: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, listId, partOfList, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfList) {
void apiRemoveAccountFromList(listId, accountId);
} else {
void apiAddAccountToList(listId, accountId);
}
onToggle(accountId);
}, [accountId, listId, partOfList, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const ListMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
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[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchList(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfList = accountIds.includes(accountId);
if (partOfList) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
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}
/>
)}
<ScrollableList
scrollKey='list_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
<div className='spacer' />
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>
</>
)
}
emptyMessage={
mode === 'remove' ? (
<>
<span>
<FormattedMessage
id='lists.no_members_yet'
defaultMessage='No members yet.'
/>
<br />
<FormattedMessage
id='lists.find_users_to_add'
defaultMessage='Find users to add'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
) : (
<FormattedMessage
id='lists.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<AccountItem
key={accountId}
accountId={accountId}
listId={id}
partOfList={
displayedAccountIds === accountIds ||
accountIds.includes(accountId)
}
onToggle={handleAccountToggle}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default ListMembers;

View File

@ -0,0 +1,296 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory, Link } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import Toggle from 'react-toggle';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchList } from 'flavours/glitch/actions/lists';
import { createList, updateList } from 'flavours/glitch/actions/lists_typed';
import { apiGetAccounts } from 'flavours/glitch/api/lists';
import type { RepliesPolicyType } from 'flavours/glitch/api_types/lists';
import Column from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
create: { id: 'column.create_list', defaultMessage: 'Create list' },
});
const MembersLink: React.FC<{
id: string;
}> = ({ id }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
useEffect(() => {
void apiGetAccounts(id)
.then((data) => {
setCount(data.length);
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars]);
return (
<Link to={`/lists/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='lists.list_members'
defaultMessage='List members'
/>
</strong>
<FormattedMessage
id='lists.list_members_count'
defaultMessage='{count, plural, one {# member} other {# members}}'
values={{ count }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
))}
</div>
</Link>
);
};
const NewList: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const list = useAppSelector((state) =>
id ? state.lists.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [exclusive, setExclusive] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchList(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && list) {
setTitle(list.title);
setExclusive(list.exclusive);
setRepliesPolicy(list.replies_policy);
}
}, [setTitle, setExclusive, setRepliesPolicy, id, list]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleExclusiveChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setExclusive(checked);
},
[setExclusive],
);
const handleRepliesPolicyChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setRepliesPolicy(value as RepliesPolicyType);
},
[setRepliesPolicy],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateList({
id,
title,
exclusive,
replies_policy: repliesPolicy,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createList({
title,
exclusive,
replies_policy: repliesPolicy,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/lists/${result.payload.id}/edit`);
history.push(`/lists/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_title'>
<FormattedMessage
id='lists.list_name'
defaultMessage='List name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='list_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_replies_policy'>
<FormattedMessage
id='lists.show_replies_to'
defaultMessage='Include replies from list members to'
/>
</label>
<div className='label_input__wrapper'>
<select
id='list_replies_policy'
value={repliesPolicy}
onChange={handleRepliesPolicyChange}
>
<FormattedMessage
id='lists.replies_policy.none'
defaultMessage='No one'
>
{(msg) => <option value='none'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.list'
defaultMessage='Members of the list'
>
{(msg) => <option value='list'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.followed'
defaultMessage='Any followed user'
>
{(msg) => <option value='followed'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
</div>
{id && (
<div className='fields-group'>
<MembersLink id={id} />
</div>
)}
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='lists.exclusive'
defaultMessage='Hide members in Home'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='lists.exclusive_hint'
defaultMessage='If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={exclusive}
onChange={handleExclusiveChange}
/>
</div>
</div>
</label>
</div>
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage id='lists.create' defaultMessage='Create' />
)}
</button>
</div>
</form>
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewList;

View File

@ -1,25 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
import Account from 'flavours/glitch/features/list_editor/components/account';
import { makeGetAccount } from 'flavours/glitch/selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(unpinAccount(accountId)),
onAdd: () => dispatch(pinAccount(accountId)),
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

View File

@ -1,24 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import Search from 'flavours/glitch/features/list_editor/components/search';
import {
fetchPinnedAccountsSuggestions,
clearPinnedAccountsSuggestions,
changePinnedAccountsSuggestions,
} from '../../../actions/accounts';
const mapStateToProps = state => ({
value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));

View File

@ -1,82 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
import AccountContainer from './containers/account_container';
import SearchContainer from './containers/search_container';
const mapStateToProps = state => ({
accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: () => dispatch(fetchPinnedAccounts()),
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
onReset: () => dispatch(resetPinnedAccountsEditor()),
});
class PinnedAccountsEditor extends ImmutablePureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize } = this.props;
onInitialize();
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
</div>)
}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(PinnedAccountsEditor));

View File

@ -11,9 +11,7 @@ import {
ReportModal,
SettingsModal,
EmbedModal,
ListEditor,
ListAdder,
PinnedAccountsEditor,
CompareHistoryModal,
FilterModal,
InteractionModal,
@ -73,10 +71,8 @@ export const MODAL_COMPONENTS = {
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,

View File

@ -62,11 +62,13 @@ import {
FollowedTags,
LinkTimeline,
ListTimeline,
Lists,
ListEdit,
ListMembers,
Blocks,
DomainBlocks,
Mutes,
PinnedStatuses,
Lists,
GettingStartedMisc,
Directory,
Explore,
@ -216,6 +218,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
<WrappedRoute path='/lists/new' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />

View File

@ -46,10 +46,6 @@ export function Lists () {
return import(/* webpackChunkName: "flavours/glitch/async/lists" */'../../lists');
}
export function PinnedAccountsEditor () {
return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'../../pinned_accounts_editor');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'../../direct_timeline');
}
@ -162,10 +158,6 @@ export function EmbedModal () {
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'../components/embed_modal');
}
export function ListEditor () {
return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'../../list_editor');
}
export function ListAdder () {
return import(/* webpackChunkName: "features/glitch/async/list_adder" */'../../list_adder');
}
@ -233,3 +225,11 @@ export function LinkTimeline () {
export function AnnualReportModal () {
return import(/*webpackChunkName: "flavours/glitch/async/modals/annual_report_modal" */'../components/annual_report_modal');
}
export function ListEdit () {
return import(/*webpackChunkName: "flavours/glitch/async/lists" */'../../lists/new');
}
export function ListMembers () {
return import(/* webpackChunkName: "flavours/glitch/async/lists" */'../../lists/members');
}

View File

@ -35,7 +35,6 @@
"confirmations.missing_media_description.edit": "Edit media",
"confirmations.missing_media_description.message": "At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.",
"direct.group_by_conversations": "Group by conversation",
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
"favourite_modal.favourite": "Favourite post?",
"federation.federated.long": "Allow this post to reach other servers",
"federation.federated.short": "Federated",
@ -51,7 +50,6 @@
"keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
"moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
"navigation_bar.app_settings": "App settings",
"navigation_bar.featured_users": "Featured users",
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.misc": "Misc",
"notification.markForDeletion": "Mark for deletion",

View File

@ -0,0 +1,18 @@
import type { RecordOf } from 'immutable';
import { Record } from 'immutable';
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
type ListShape = Required<ApiListJSON>; // no changes from server shape
export type List = RecordOf<ListShape>;
const ListFactory = Record<ListShape>({
id: '',
title: '',
exclusive: false,
replies_policy: 'list',
});
export function createList(attributes: Partial<ListShape>) {
return ListFactory(attributes);
}

View File

@ -17,6 +17,7 @@ export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
interface BaseNotificationGroup
extends Omit<BaseNotificationGroupJSON, 'sample_account_ids'> {
sampleAccountIds: string[];
partial: boolean;
}
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
@ -142,6 +143,7 @@ export function createNotificationGroupFromJSON(
return {
statusId: statusId ?? undefined,
sampleAccountIds,
partial: false,
...groupWithoutStatus,
};
}
@ -150,12 +152,14 @@ export function createNotificationGroupFromJSON(
return {
report: createReportFromJSON(report),
sampleAccountIds,
partial: false,
...groupWithoutTargetAccount,
};
}
case 'severed_relationships':
return {
...group,
partial: false,
event: createAccountRelationshipSeveranceEventFromJSON(group.event),
sampleAccountIds,
};
@ -163,6 +167,7 @@ export function createNotificationGroupFromJSON(
const { moderation_warning, ...groupWithoutModerationWarning } = group;
return {
...groupWithoutModerationWarning,
partial: false,
moderationWarning: createAccountWarningFromJSON(moderation_warning),
sampleAccountIds,
};
@ -171,6 +176,7 @@ export function createNotificationGroupFromJSON(
const { annual_report, ...groupWithoutAnnualReport } = group;
return {
...groupWithoutAnnualReport,
partial: false,
annualReport: createAnnualReportEventFromJSON(annual_report),
sampleAccountIds,
};
@ -178,6 +184,7 @@ export function createNotificationGroupFromJSON(
default:
return {
sampleAccountIds,
partial: false,
...group,
};
}
@ -185,17 +192,17 @@ export function createNotificationGroupFromJSON(
export function createNotificationGroupFromNotificationJSON(
notification: ApiNotificationJSON,
) {
): NotificationGroup {
const group = {
sampleAccountIds: [notification.account.id],
group_key: notification.group_key,
notifications_count: 1,
type: notification.type,
most_recent_notification_id: notification.id,
page_min_id: notification.id,
page_max_id: notification.id,
latest_page_notification_at: notification.created_at,
} as NotificationGroup;
partial: true,
};
switch (notification.type) {
case 'favourite':
@ -204,12 +211,21 @@ export function createNotificationGroupFromNotificationJSON(
case 'mention':
case 'poll':
case 'update':
return { ...group, statusId: notification.status?.id };
return {
...group,
type: notification.type,
statusId: notification.status?.id,
};
case 'admin.report':
return { ...group, report: createReportFromJSON(notification.report) };
return {
...group,
type: notification.type,
report: createReportFromJSON(notification.report),
};
case 'severed_relationships':
return {
...group,
type: notification.type,
event: createAccountRelationshipSeveranceEventFromJSON(
notification.event,
),
@ -217,11 +233,15 @@ export function createNotificationGroupFromNotificationJSON(
case 'moderation_warning':
return {
...group,
type: notification.type,
moderationWarning: createAccountWarningFromJSON(
notification.moderation_warning,
),
};
default:
return group;
return {
...group,
type: notification.type,
};
}
}

View File

@ -17,9 +17,7 @@ import filters from './filters';
import followed_tags from './followed_tags';
import height_cache from './height_cache';
import history from './history';
import listAdder from './list_adder';
import listEditor from './list_editor';
import lists from './lists';
import { listsReducer } from './lists';
import local_settings from './local_settings';
import { markersReducer } from './markers';
import media_attachments from './media_attachments';
@ -30,7 +28,6 @@ import { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests';
import notifications from './notifications';
import { pictureInPictureReducer } from './picture_in_picture';
import pinnedAccountsEditor from './pinned_accounts_editor';
import polls from './polls';
import push_notifications from './push_notifications';
import { relationshipsReducer } from './relationships';
@ -72,13 +69,10 @@ const reducers = {
notificationGroups: notificationGroupsReducer,
height_cache,
custom_emojis,
lists,
listEditor,
listAdder,
lists: listsReducer,
filters,
conversations,
suggestions,
pinnedAccountsEditor,
polls,
trends,
markers: markersReducer,

View File

@ -1,48 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
LIST_ADDER_RESET,
LIST_ADDER_SETUP,
LIST_ADDER_LISTS_FETCH_REQUEST,
LIST_ADDER_LISTS_FETCH_SUCCESS,
LIST_ADDER_LISTS_FETCH_FAIL,
LIST_EDITOR_ADD_SUCCESS,
LIST_EDITOR_REMOVE_SUCCESS,
} from '../actions/lists';
const initialState = ImmutableMap({
accountId: null,
lists: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
});
export default function listAdderReducer(state = initialState, action) {
switch(action.type) {
case LIST_ADDER_RESET:
return initialState;
case LIST_ADDER_SETUP:
return state.withMutations(map => {
map.set('accountId', action.account.get('id'));
});
case LIST_ADDER_LISTS_FETCH_REQUEST:
return state.setIn(['lists', 'isLoading'], true);
case LIST_ADDER_LISTS_FETCH_FAIL:
return state.setIn(['lists', 'isLoading'], false);
case LIST_ADDER_LISTS_FETCH_SUCCESS:
return state.update('lists', lists => lists.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.lists.map(item => item.id)));
}));
case LIST_EDITOR_ADD_SUCCESS:
return state.updateIn(['lists', 'items'], list => list.unshift(action.listId));
case LIST_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId));
default:
return state;
}
}

View File

@ -1,99 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
LIST_CREATE_REQUEST,
LIST_CREATE_FAIL,
LIST_CREATE_SUCCESS,
LIST_UPDATE_REQUEST,
LIST_UPDATE_FAIL,
LIST_UPDATE_SUCCESS,
LIST_EDITOR_RESET,
LIST_EDITOR_SETUP,
LIST_EDITOR_TITLE_CHANGE,
LIST_ACCOUNTS_FETCH_REQUEST,
LIST_ACCOUNTS_FETCH_SUCCESS,
LIST_ACCOUNTS_FETCH_FAIL,
LIST_EDITOR_SUGGESTIONS_READY,
LIST_EDITOR_SUGGESTIONS_CLEAR,
LIST_EDITOR_SUGGESTIONS_CHANGE,
LIST_EDITOR_ADD_SUCCESS,
LIST_EDITOR_REMOVE_SUCCESS,
} from '../actions/lists';
const initialState = ImmutableMap({
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
isExclusive: false,
accounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function listEditorReducer(state = initialState, action) {
switch(action.type) {
case LIST_EDITOR_RESET:
return initialState;
case LIST_EDITOR_SETUP:
return state.withMutations(map => {
map.set('listId', action.list.get('id'));
map.set('title', action.list.get('title'));
map.set('isExclusive', action.list.get('is_exclusive'));
map.set('isSubmitting', false);
});
case LIST_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case LIST_CREATE_REQUEST:
case LIST_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case LIST_CREATE_FAIL:
case LIST_UPDATE_FAIL:
return state.set('isSubmitting', false);
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('listId', action.list.id);
});
case LIST_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true);
case LIST_ACCOUNTS_FETCH_FAIL:
return state.setIn(['accounts', 'isLoading'], false);
case LIST_ACCOUNTS_FETCH_SUCCESS:
return state.update('accounts', accounts => accounts.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
case LIST_EDITOR_SUGGESTIONS_CHANGE:
return state.setIn(['suggestions', 'value'], action.value);
case LIST_EDITOR_SUGGESTIONS_READY:
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
case LIST_EDITOR_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
}));
case LIST_EDITOR_ADD_SUCCESS:
return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId));
case LIST_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId));
default:
return state;
}
}

View File

@ -1,38 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
LIST_FETCH_SUCCESS,
LIST_FETCH_FAIL,
LISTS_FETCH_SUCCESS,
LIST_CREATE_SUCCESS,
LIST_UPDATE_SUCCESS,
LIST_DELETE_SUCCESS,
} from '../actions/lists';
const initialState = ImmutableMap();
const normalizeList = (state, list) => state.set(list.id, fromJS(list));
const normalizeLists = (state, lists) => {
lists.forEach(list => {
state = normalizeList(state, list);
});
return state;
};
export default function lists(state = initialState, action) {
switch(action.type) {
case LIST_FETCH_SUCCESS:
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
return normalizeList(state, action.list);
case LISTS_FETCH_SUCCESS:
return normalizeLists(state, action.lists);
case LIST_DELETE_SUCCESS:
case LIST_FETCH_FAIL:
return state.set(action.id, false);
default:
return state;
}
}

View File

@ -0,0 +1,49 @@
import type { Reducer } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import { createList, updateList } from 'flavours/glitch/actions/lists_typed';
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
import { createList as createListFromJSON } from 'flavours/glitch/models/list';
import type { List } from 'flavours/glitch/models/list';
import {
LIST_FETCH_SUCCESS,
LIST_FETCH_FAIL,
LISTS_FETCH_SUCCESS,
LIST_DELETE_SUCCESS,
} from '../actions/lists';
const initialState = ImmutableMap<string, List | null>();
type State = typeof initialState;
const normalizeList = (state: State, list: ApiListJSON) =>
state.set(list.id, createListFromJSON(list));
const normalizeLists = (state: State, lists: ApiListJSON[]) => {
lists.forEach((list) => {
state = normalizeList(state, list);
});
return state;
};
export const listsReducer: Reducer<State> = (state = initialState, action) => {
if (
createList.fulfilled.match(action) ||
updateList.fulfilled.match(action)
) {
return normalizeList(state, action.payload);
} else {
switch (action.type) {
case LIST_FETCH_SUCCESS:
return normalizeList(state, action.list as ApiListJSON);
case LISTS_FETCH_SUCCESS:
return normalizeLists(state, action.lists as ApiListJSON[]);
case LIST_DELETE_SUCCESS:
case LIST_FETCH_FAIL:
return state.set(action.id as string, null);
default:
return state;
}
}
};

View File

@ -534,10 +534,13 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
if (existingGroupIndex > -1) {
const existingGroup = state.groups[existingGroupIndex];
if (existingGroup && existingGroup.type !== 'gap') {
group.notifications_count += existingGroup.notifications_count;
group.sampleAccountIds = group.sampleAccountIds
.concat(existingGroup.sampleAccountIds)
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
if (group.partial) {
group.notifications_count +=
existingGroup.notifications_count;
group.sampleAccountIds = group.sampleAccountIds
.concat(existingGroup.sampleAccountIds)
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
}
state.groups.splice(existingGroupIndex, 1);
}
}

View File

@ -1,58 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
PINNED_ACCOUNTS_EDITOR_RESET,
PINNED_ACCOUNTS_FETCH_REQUEST,
PINNED_ACCOUNTS_FETCH_SUCCESS,
PINNED_ACCOUNTS_FETCH_FAIL,
PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
pinAccountSuccess,
unpinAccountSuccess,
} from '../actions/accounts';
const initialState = ImmutableMap({
accounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function listEditorReducer(state = initialState, action) {
switch(action.type) {
case PINNED_ACCOUNTS_EDITOR_RESET:
return initialState;
case PINNED_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true);
case PINNED_ACCOUNTS_FETCH_FAIL:
return state.setIn(['accounts', 'isLoading'], false);
case PINNED_ACCOUNTS_FETCH_SUCCESS:
return state.update('accounts', accounts => accounts.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
case PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS:
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE:
return state.setIn(['suggestions', 'value'], action.value);
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
}));
case pinAccountSuccess.type:
return state.updateIn(['accounts', 'items'], list => list.unshift(action.payload.relationship.id));
case unpinAccountSuccess.type:
return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.payload.relationship.id));
default:
return state;
}
}

View File

@ -0,0 +1,15 @@
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import type { List } from 'flavours/glitch/models/list';
import type { RootState } from 'flavours/glitch/store';
export const getOrderedLists = createSelector(
[(state: RootState) => state.lists],
(lists: ImmutableMap<string, List | null>) =>
lists
.toList()
.filter((item: List | null) => !!item)
.sort((a: List, b: List) => a.title.localeCompare(b.title))
.toArray(),
);

View File

@ -630,16 +630,6 @@ body,
max-width: 100%;
}
.simple_form {
.actions {
margin-top: 15px;
}
.button {
font-size: 15px;
}
}
.batch-form-box {
display: flex;
flex-wrap: wrap;

View File

@ -4991,6 +4991,7 @@ a.status-card {
border: 0;
background: transparent;
cursor: pointer;
text-decoration: none;
.icon {
width: 13px;
@ -5408,7 +5409,8 @@ a.status-card {
color: $dark-text-color;
text-align: center;
padding: 20px;
font-size: 15px;
font-size: 14px;
line-height: 20px;
font-weight: 400;
cursor: default;
display: flex;
@ -5430,6 +5432,17 @@ a.status-card {
}
}
.empty-column-indicator {
&__arrow {
position: absolute;
top: 50%;
inset-inline-start: 50%;
pointer-events: none;
transform: translate(100%, -100%) rotate(12deg);
transform-origin: center;
}
}
.follow_requests-unlocked_explanation {
background: var(--surface-background-color);
border-bottom: 1px solid var(--background-border-color);
@ -6233,7 +6246,7 @@ a.status-card {
.modal-root {
position: relative;
z-index: 9999;
z-index: 9998;
}
.modal-root__overlay {
@ -6838,12 +6851,14 @@ a.status-card {
border-radius: 16px;
&__header {
box-sizing: border-box;
border-bottom: 1px solid var(--modal-border-color);
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: row-reverse;
padding: 12px 24px;
min-height: 61px;
&__title {
font-size: 16px;
@ -8521,92 +8536,6 @@ noscript {
background: rgba($base-overlay-background, 0.5);
}
.list-adder,
.list-editor {
backdrop-filter: var(--background-filter);
background: var(--modal-background-color);
border: 1px solid var(--modal-border-color);
flex-direction: column;
border-radius: 8px;
width: 380px;
overflow: hidden;
@media screen and (width <= 420px) {
width: 90%;
}
}
.list-adder {
&__lists {
height: 50vh;
border-radius: 0 0 8px 8px;
overflow-y: auto;
}
.list {
padding: 10px;
border-bottom: 1px solid var(--background-border-color);
}
.list__wrapper {
display: flex;
}
.list__display-name {
flex: 1 1 auto;
overflow: hidden;
text-decoration: none;
font-size: 16px;
padding: 10px;
display: flex;
align-items: center;
gap: 4px;
}
}
.list-editor {
h4 {
padding: 15px 0;
background: lighten($ui-base-color, 13%);
font-weight: 500;
font-size: 16px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.drawer__pager {
height: 50vh;
border: 0;
}
.drawer__inner {
&.backdrop {
width: calc(100% - 60px);
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
border-radius: 0 0 0 8px;
}
}
&__accounts {
background: unset;
overflow-y: auto;
}
.account__display-name {
&:hover strong {
text-decoration: none;
}
}
.account__avatar {
cursor: default;
}
.search {
margin-bottom: 0;
}
}
.focal-point {
position: relative;
cursor: move;
@ -10677,7 +10606,7 @@ noscript {
position: fixed;
bottom: 2rem;
inset-inline-start: 0;
z-index: 999;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 4px;
@ -11686,3 +11615,87 @@ noscript {
height: 20px;
}
}
.lists__item {
display: flex;
align-items: center;
gap: 16px;
padding-inline-end: 13px;
border-bottom: 1px solid var(--background-border-color);
&__title {
display: flex;
align-items: center;
gap: 5px;
padding: 16px 13px;
flex: 1 1 auto;
font-size: 16px;
line-height: 24px;
color: $secondary-text-color;
text-decoration: none;
&:is(a):hover,
&:is(a):focus,
&:is(a):active {
color: $primary-text-color;
}
input {
display: block;
width: 100%;
background: transparent;
border: 0;
padding: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
&::placeholder {
color: var(--input-placeholder-color);
opacity: 1;
}
&:focus {
outline: 0;
}
}
}
}
.column-search-header {
display: flex;
border-radius: 4px 4px 0 0;
border: 1px solid var(--background-border-color);
.column-header__back-button.compact {
flex: 0 0 auto;
color: $primary-text-color;
}
input {
background: transparent;
border: 0;
color: $primary-text-color;
font-size: 16px;
display: block;
flex: 1 1 auto;
&::placeholder {
color: var(--input-placeholder-color);
opacity: 1;
}
&:focus {
outline: 0;
}
}
}
.column-footer {
padding: 16px;
}
.lists-scrollable {
min-height: 50vh;
}

View File

@ -1256,6 +1256,8 @@ code {
}
.app-form {
padding: 20px;
&__avatar-input,
&__header-input {
display: block;
@ -1371,4 +1373,55 @@ code {
padding-inline-start: 16px;
}
}
&__link {
display: flex;
gap: 16px;
padding: 8px 0;
align-items: center;
text-decoration: none;
color: $primary-text-color;
margin-bottom: 16px;
&__text {
flex: 1 1 auto;
font-size: 14px;
line-height: 20px;
color: $darker-text-color;
strong {
font-weight: 600;
display: block;
color: $primary-text-color;
}
}
}
}
.avatar-pile {
display: flex;
align-items: center;
img {
display: block;
border-radius: 8px;
width: 32px;
height: 32px;
border: 2px solid var(--background-color);
background: var(--surface-background-color);
margin-inline-end: -16px;
transform: rotate(0);
&:first-child {
transform: rotate(-4deg);
}
&:nth-child(2) {
transform: rotate(-2deg);
}
&:last-child {
margin-inline-end: 0;
}
}
}

View File

@ -79,4 +79,5 @@ body {
--rich-text-container-color: rgba(255, 216, 231, 100%);
--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)};
}

View File

@ -125,4 +125,5 @@ $dismiss-overlay-width: 4rem;
--rich-text-container-color: rgba(87, 24, 60, 100%);
--rich-text-text-color: rgba(255, 175, 212, 100%);
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
--input-placeholder-color: #{$dark-text-color};
}

View File

@ -1,8 +1,5 @@
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
@ -11,45 +8,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
export const fetchList = id => (dispatch, getState) => {
if (getState().getIn(['lists', id])) {
return;
@ -100,89 +62,6 @@ export const fetchListsFail = error => ({
error,
});
export const submitListEditor = shouldReset => (dispatch, getState) => {
const listId = getState().getIn(['listEditor', 'listId']);
const title = getState().getIn(['listEditor', 'title']);
if (listId === null) {
dispatch(createList(title, shouldReset));
} else {
dispatch(updateList(listId, title, shouldReset));
}
};
export const setupListEditor = listId => (dispatch, getState) => {
dispatch({
type: LIST_EDITOR_SETUP,
list: getState().getIn(['lists', listId]),
});
dispatch(fetchListAccounts(listId));
};
export const changeListEditorTitle = value => ({
type: LIST_EDITOR_TITLE_CHANGE,
value,
});
export const createList = (title, shouldReset) => (dispatch) => {
dispatch(createListRequest());
api().post('/api/v1/lists', { title }).then(({ data }) => {
dispatch(createListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(createListFail(err)));
};
export const createListRequest = () => ({
type: LIST_CREATE_REQUEST,
});
export const createListSuccess = list => ({
type: LIST_CREATE_SUCCESS,
list,
});
export const createListFail = error => ({
type: LIST_CREATE_FAIL,
error,
});
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => {
dispatch(updateListRequest(id));
api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(updateListFail(id, err)));
};
export const updateListRequest = id => ({
type: LIST_UPDATE_REQUEST,
id,
});
export const updateListSuccess = list => ({
type: LIST_UPDATE_SUCCESS,
list,
});
export const updateListFail = (id, error) => ({
type: LIST_UPDATE_FAIL,
id,
error,
});
export const resetListEditor = () => ({
type: LIST_EDITOR_RESET,
});
export const deleteList = id => (dispatch) => {
dispatch(deleteListRequest(id));
@ -206,167 +85,3 @@ export const deleteListFail = (id, error) => ({
id,
error,
});
export const fetchListAccounts = listId => (dispatch) => {
dispatch(fetchListAccountsRequest(listId));
api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListAccountsSuccess(listId, data));
}).catch(err => dispatch(fetchListAccountsFail(listId, err)));
};
export const fetchListAccountsRequest = id => ({
type: LIST_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchListAccountsSuccess = (id, accounts, next) => ({
type: LIST_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchListAccountsFail = (id, error) => ({
type: LIST_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchListSuggestions = q => (dispatch) => {
const params = {
q,
resolve: false,
limit: 4,
following: true,
};
api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchListSuggestionsReady = (query, accounts) => ({
type: LIST_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearListSuggestions = () => ({
type: LIST_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeListSuggestions = value => ({
type: LIST_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToListEditor = accountId => (dispatch, getState) => {
dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const addToList = (listId, accountId) => (dispatch) => {
dispatch(addToListRequest(listId, accountId));
api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addToListSuccess(listId, accountId)))
.catch(err => dispatch(addToListFail(listId, accountId, err)));
};
export const addToListRequest = (listId, accountId) => ({
type: LIST_EDITOR_ADD_REQUEST,
listId,
accountId,
});
export const addToListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_ADD_SUCCESS,
listId,
accountId,
});
export const addToListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_ADD_FAIL,
listId,
accountId,
error,
});
export const removeFromListEditor = accountId => (dispatch, getState) => {
dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const removeFromList = (listId, accountId) => (dispatch) => {
dispatch(removeFromListRequest(listId, accountId));
api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeFromListSuccess(listId, accountId)))
.catch(err => dispatch(removeFromListFail(listId, accountId, err)));
};
export const removeFromListRequest = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_REQUEST,
listId,
accountId,
});
export const removeFromListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_SUCCESS,
listId,
accountId,
});
export const removeFromListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_REMOVE_FAIL,
listId,
accountId,
error,
});
export const resetListAdder = () => ({
type: LIST_ADDER_RESET,
});
export const setupListAdder = accountId => (dispatch, getState) => {
dispatch({
type: LIST_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchLists());
dispatch(fetchAccountLists(accountId));
};
export const fetchAccountLists = accountId => (dispatch) => {
dispatch(fetchAccountListsRequest(accountId));
api().get(`/api/v1/accounts/${accountId}/lists`)
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountListsFail(accountId, err)));
};
export const fetchAccountListsRequest = id => ({
type:LIST_ADDER_LISTS_FETCH_REQUEST,
id,
});
export const fetchAccountListsSuccess = (id, lists) => ({
type: LIST_ADDER_LISTS_FETCH_SUCCESS,
id,
lists,
});
export const fetchAccountListsFail = (id, err) => ({
type: LIST_ADDER_LISTS_FETCH_FAIL,
id,
err,
});
export const addToListAdder = listId => (dispatch, getState) => {
dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
};
export const removeFromListAdder = listId => (dispatch, getState) => {
dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
};

View File

@ -0,0 +1,13 @@
import { apiCreate, apiUpdate } from 'mastodon/api/lists';
import type { List } from 'mastodon/models/list';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createList = createDataLoadingThunk(
'list/create',
(list: Partial<List>) => apiCreate(list),
);
export const updateList = createDataLoadingThunk(
'list/update',
(list: Partial<List>) => apiUpdate(list),
);

View File

@ -68,6 +68,7 @@ export async function apiRequest<ApiResponse = unknown>(
method: Method,
url: string,
args: {
signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
timeout?: number;

View File

@ -0,0 +1,32 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiListJSON } from 'mastodon/api_types/lists';
export const apiCreate = (list: Partial<ApiListJSON>) =>
apiRequestPost<ApiListJSON>('v1/lists', list);
export const apiUpdate = (list: Partial<ApiListJSON>) =>
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
export const apiGetAccounts = (listId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
limit: 0,
});
export const apiGetAccountLists = (accountId: string) =>
apiRequestGet<ApiListJSON[]>(`v1/accounts/${accountId}/lists`);
export const apiAddAccountToList = (listId: string, accountId: string) =>
apiRequestPost(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});
export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});

View File

@ -0,0 +1,10 @@
// See app/serializers/rest/list_serializer.rb
export type RepliesPolicyType = 'list' | 'followed' | 'none';
export interface ApiListJSON {
id: string;
title: string;
exclusive: boolean;
replies_policy: RepliesPolicyType;
}

View File

@ -7,11 +7,11 @@ import { Icon } from './icon';
interface Props {
value: string;
checked: boolean;
indeterminate: boolean;
name: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
label: React.ReactNode;
checked?: boolean;
indeterminate?: boolean;
name?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
label?: React.ReactNode;
}
export const CheckBox: React.FC<Props> = ({
@ -30,6 +30,7 @@ export const CheckBox: React.FC<Props> = ({
value={value}
checked={checked}
onChange={onChange}
readOnly={!onChange}
/>
<span
@ -42,7 +43,7 @@ export const CheckBox: React.FC<Props> = ({
)}
</span>
<span>{label}</span>
{label && <span>{label}</span>}
</label>
);
};

View File

@ -80,6 +80,7 @@ class ScrollableList extends PureComponent {
children: PropTypes.node,
bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool,
footer: PropTypes.node,
};
static defaultProps = {
@ -324,7 +325,7 @@ class ScrollableList extends PureComponent {
};
render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = Children.count(children);
@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
<div className='scrollable__append'>
<LoadingIndicator />
</div>
{footer}
</div>
);
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
scrollableArea = (
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend}
@ -375,6 +378,8 @@ class ScrollableList extends PureComponent {
{!hasMore && append}
</div>
{footer}
</div>
);
} else {
@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
<div className='empty-column-indicator'>
{emptyMessage}
</div>
{footer}
</div>
);
}

View File

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -164,32 +166,18 @@ class Status extends ImmutablePureComponent {
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleHotkeyOpen(e);
};
handleMouseUp = e => {
// Only handle clicks on the empty space above the content
if (e.target !== e.currentTarget) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpen();
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this._openProfile(proper);
this.handleClick(e);
};
handleExpandedToggle = () => {
@ -287,7 +275,7 @@ class Status extends ImmutablePureComponent {
this.props.onMention(this._properStatus().get('account'));
};
handleHotkeyOpen = () => {
handleHotkeyOpen = (e) => {
if (this.props.onClick) {
this.props.onClick();
return;
@ -300,7 +288,13 @@ class Status extends ImmutablePureComponent {
return;
}
history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`;
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
history.push(path);
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
}
};
handleHotkeyOpenProfile = () => {
@ -412,7 +406,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <Link data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} to={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></Link> }} />
</div>
);
@ -550,20 +544,19 @@ class Status extends ImmutablePureComponent {
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<div onMouseUp={this.handleMouseUp} className='status__info'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time'>
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility')} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
</Link>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<Link to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</Link>
</div>
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}

View File

@ -204,8 +204,8 @@ class StatusContent extends PureComponent {
element = element.parentNode;
}
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
this.props.onClick();
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && this.props.onClick) {
this.props.onClick(e);
}
this.startXY = null;

View File

@ -15,6 +15,13 @@ const mapStateToProps = state => ({
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
if (status) {

View File

@ -1,43 +0,0 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View File

@ -1,75 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const MapStateToProps = (state, { listId, added }) => ({
list: state.get('lists').get(listId),
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
});
const mapDispatchToProps = (dispatch, { listId }) => ({
onRemove: () => dispatch(removeFromListAdder(listId)),
onAdd: () => dispatch(addToListAdder(listId)),
});
class List extends ImmutablePureComponent {
static propTypes = {
list: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { list, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
{list.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));

View File

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupListAdder, resetListAdder } from '../../actions/lists';
import NewListForm from '../lists/components/new_list_form';
import Account from './components/account';
import List from './components/list';
// hack
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
listIds: getOrderedLists(state).map(list=>list.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupListAdder(accountId)),
onReset: () => dispatch(resetListAdder()),
});
class ListAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
listIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, listIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewListForm />
<div className='list-adder__lists'>
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder));

View File

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { createList } from 'mastodon/actions/lists_typed';
import {
apiGetAccountLists,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiListJSON } from 'mastodon/api_types/lists';
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 { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newList: {
id: 'lists.new_list_name',
defaultMessage: 'New list name',
},
createList: {
id: 'lists.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const ListItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewListItem: React.FC<{
onCreate: (list: ApiListJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createList({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newList)}
/>
</label>
<Button text={intl.formatMessage(messages.createList)} type='submit' />
</form>
);
};
const ListAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const lists = useAppSelector((state) => getOrderedLists(state));
const [listIds, setListIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchLists());
apiGetAccountLists(accountId)
.then((data) => {
setListIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setListIds, accountId]);
const handleToggle = useCallback(
(listId: string, checked: boolean) => {
if (checked) {
setListIds((currentListIds) => [listId, ...currentListIds]);
apiAddAccountToList(listId, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
});
} else {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
apiRemoveAccountFromList(listId, accountId).catch(() => {
setListIds((currentListIds) => [listId, ...currentListIds]);
});
}
},
[setListIds, accountId],
);
const handleCreate = useCallback(
(list: ApiListJSON) => {
setListIds((currentListIds) => [list.id, ...currentListIds]);
apiAddAccountToList(list.id, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== list.id),
);
});
},
[setListIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='lists.add_to_lists'
defaultMessage='Add {name} to lists'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewListItem onCreate={handleCreate} />
{lists.map((list) => (
<ListItem
key={list.id}
id={list.id}
title={list.title}
checked={listIds.includes(list.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default ListAdder;

View File

@ -1,82 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View File

@ -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 { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(false)),
});
class ListForm 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(ListForm));

View File

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View File

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditListForm from './components/edit_list_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: listId => dispatch(setupListEditor(listId)),
onClear: () => dispatch(clearListSuggestions()),
onReset: () => dispatch(resetListEditor()),
});
class ListEditor extends ImmutablePureComponent {
static propTypes = {
listId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, listId } = this.props;
onInitialize(listId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditListForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));

View File

@ -1,21 +1,19 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, updateList } from 'mastodon/actions/lists';
import { fetchList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { connectListStream } from 'mastodon/actions/streaming';
import { expandListTimeline } from 'mastodon/actions/timelines';
@ -23,17 +21,10 @@ 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 { RadioButton } from 'mastodon/components/radio_button';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
});
const mapStateToProps = (state, props) => ({
list: state.getIn(['lists', props.params.id]),
hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
@ -115,13 +106,6 @@ class ListTimeline extends PureComponent {
this.props.dispatch(expandListTimeline(id, { maxId }));
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'LIST_EDITOR',
modalProps: { listId: this.props.params.id },
}));
};
handleDeleteClick = () => {
const { dispatch, columnId } = this.props;
const { id } = this.props.params;
@ -129,25 +113,11 @@ class ListTimeline extends PureComponent {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
};
handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined));
};
render () {
const { hasUnread, columnId, multiColumn, list, intl } = this.props;
const { hasUnread, columnId, multiColumn, list } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
if (typeof list === 'undefined') {
return (
@ -178,35 +148,14 @@ class ListTimeline extends PureComponent {
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Link to={`/lists/${id}/edit`} className='text-btn column-header__setting-btn'>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
</Link>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button>
</section>
<section>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>
</div>
</section>
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</section>
)}
</div>
</ColumnHeader>
@ -229,4 +178,4 @@ class ListTimeline extends PureComponent {
}
export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline)));
export default withRouter(connect(mapStateToProps)(ListTimeline));

View File

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
title: { id: 'lists.new.create', defaultMessage: 'Add list' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: state.getIn(['listEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(true)),
});
class NewListForm 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 label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));

View File

@ -1,94 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewListForm from './components/new_list_form';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
});
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
class Lists extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchLists());
}
render () {
const { intl, lists, multiColumn } = this.props;
if (!lists) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='list-ul' iconComponent={ListAltIcon} multiColumn={multiColumn} />
<NewListForm />
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{lists.map(list =>
<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Lists));

View File

@ -0,0 +1,145 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
create: { id: 'lists.create_list', defaultMessage: 'Create list' },
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const ListItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='lists'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Lists: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='lists.no_lists_yet'
defaultMessage='No lists yet.'
/>
<br />
<FormattedMessage
id='lists.create_a_list_to_organize'
defaultMessage='Create a new list to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/lists/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Lists;

View File

@ -0,0 +1,373 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
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';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
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 { 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({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
placeholder: {
id: 'lists.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
add: { id: 'lists.add_member', defaultMessage: 'Add' },
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
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;
partOfList: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, listId, partOfList, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfList) {
void apiRemoveAccountFromList(listId, accountId);
} else {
void apiAddAccountToList(listId, accountId);
}
onToggle(accountId);
}, [accountId, listId, partOfList, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const ListMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
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[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchList(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfList = accountIds.includes(accountId);
if (partOfList) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
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}
/>
)}
<ScrollableList
scrollKey='list_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
<div className='spacer' />
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>
</>
)
}
emptyMessage={
mode === 'remove' ? (
<>
<span>
<FormattedMessage
id='lists.no_members_yet'
defaultMessage='No members yet.'
/>
<br />
<FormattedMessage
id='lists.find_users_to_add'
defaultMessage='Find users to add'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
) : (
<FormattedMessage
id='lists.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<AccountItem
key={accountId}
accountId={accountId}
listId={id}
partOfList={
displayedAccountIds === accountIds ||
accountIds.includes(accountId)
}
onToggle={handleAccountToggle}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default ListMembers;

View File

@ -0,0 +1,296 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory, Link } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import Toggle from 'react-toggle';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchList } from 'mastodon/actions/lists';
import { createList, updateList } from 'mastodon/actions/lists_typed';
import { apiGetAccounts } from 'mastodon/api/lists';
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
create: { id: 'column.create_list', defaultMessage: 'Create list' },
});
const MembersLink: React.FC<{
id: string;
}> = ({ id }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
useEffect(() => {
void apiGetAccounts(id)
.then((data) => {
setCount(data.length);
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars]);
return (
<Link to={`/lists/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='lists.list_members'
defaultMessage='List members'
/>
</strong>
<FormattedMessage
id='lists.list_members_count'
defaultMessage='{count, plural, one {# member} other {# members}}'
values={{ count }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
))}
</div>
</Link>
);
};
const NewList: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const list = useAppSelector((state) =>
id ? state.lists.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [exclusive, setExclusive] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchList(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && list) {
setTitle(list.title);
setExclusive(list.exclusive);
setRepliesPolicy(list.replies_policy);
}
}, [setTitle, setExclusive, setRepliesPolicy, id, list]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleExclusiveChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setExclusive(checked);
},
[setExclusive],
);
const handleRepliesPolicyChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setRepliesPolicy(value as RepliesPolicyType);
},
[setRepliesPolicy],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateList({
id,
title,
exclusive,
replies_policy: repliesPolicy,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createList({
title,
exclusive,
replies_policy: repliesPolicy,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/lists/${result.payload.id}/edit`);
history.push(`/lists/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_title'>
<FormattedMessage
id='lists.list_name'
defaultMessage='List name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='list_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='list_replies_policy'>
<FormattedMessage
id='lists.show_replies_to'
defaultMessage='Include replies from list members to'
/>
</label>
<div className='label_input__wrapper'>
<select
id='list_replies_policy'
value={repliesPolicy}
onChange={handleRepliesPolicyChange}
>
<FormattedMessage
id='lists.replies_policy.none'
defaultMessage='No one'
>
{(msg) => <option value='none'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.list'
defaultMessage='Members of the list'
>
{(msg) => <option value='list'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='lists.replies_policy.followed'
defaultMessage='Any followed user'
>
{(msg) => <option value='followed'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
</div>
{id && (
<div className='fields-group'>
<MembersLink id={id} />
</div>
)}
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='lists.exclusive'
defaultMessage='Hide members in Home'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='lists.exclusive_hint'
defaultMessage='If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={exclusive}
onChange={handleExclusiveChange}
/>
</div>
</div>
</label>
</div>
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage id='lists.create' defaultMessage='Create' />
)}
</button>
</div>
</form>
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewList;

View File

@ -43,7 +43,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
);
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY, target, button }) => {
({ clientX, clientY, target, button, ctrlKey, metaKey }) => {
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
const [deltaX, deltaY] = [
Math.abs(clientX - startX),
@ -64,8 +64,14 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
element = element.parentNode as HTMLDivElement | null;
}
if (deltaX + deltaY < 5 && button === 0 && account) {
history.push(`/@${account.acct}/${statusId}`);
if (deltaX + deltaY < 5 && account) {
const path = `/@${account.acct}/${statusId}`;
if (button === 0 && !(ctrlKey || metaKey)) {
history.push(path);
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
}
}
clickCoordinatesRef.current = null;

View File

@ -10,7 +10,6 @@ import {
DomainBlockModal,
ReportModal,
EmbedModal,
ListEditor,
ListAdder,
CompareHistoryModal,
FilterModal,
@ -64,7 +63,6 @@ export const MODAL_COMPONENTS = {
'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal,

View File

@ -58,11 +58,13 @@ import {
FollowedTags,
LinkTimeline,
ListTimeline,
Lists,
ListEdit,
ListMembers,
Blocks,
DomainBlocks,
Mutes,
PinnedStatuses,
Lists,
Directory,
Explore,
Onboarding,
@ -205,6 +207,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
<WrappedRoute path='/lists/new' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />

View File

@ -150,10 +150,6 @@ export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
}
export function ListEditor () {
return import(/* webpackChunkName: "features/list_editor" */'../../list_editor');
}
export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
}
@ -221,3 +217,11 @@ export function LinkTimeline () {
export function AnnualReportModal () {
return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal');
}
export function ListEdit () {
return import(/*webpackChunkName: "features/lists" */'../../lists/new');
}
export function ListMembers () {
return import(/* webpackChunkName: "features/lists" */'../../lists/members');
}

View File

@ -154,7 +154,6 @@
"empty_column.hashtag": "Daar is nog niks vir hierdie hutsetiket nie.",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
"empty_column.list": "Hierdie lys is nog leeg. Nuwe plasings deur lyslede sal voortaan hier verskyn.",
"empty_column.lists": "Jy het nog geen lyste nie. Wanneer jy een skep, sal dit hier vertoon.",
"empty_column.notifications": "Jy het nog geen kennisgewings nie. Interaksie van ander mense met jou, sal hier vertoon.",
"explore.search_results": "Soekresultate",
"explore.suggested_follows": "Mense",
@ -222,15 +221,8 @@
"limited_account_hint.action": "Vertoon profiel in elk geval",
"limited_account_hint.title": "Hierdie profiel is deur moderators van {domain} versteek.",
"link_preview.author": "Deur {name}",
"lists.account.add": "Voeg by lys",
"lists.account.remove": "Verwyder vanaf lys",
"lists.delete": "Verwyder lys",
"lists.edit": "Redigeer lys",
"lists.edit.submit": "Verander titel",
"lists.new.create": "Voeg lys by",
"lists.new.title_placeholder": "Nuwe lys titel",
"lists.search": "Soek tussen mense wat jy volg",
"lists.subheading": "Jou lyste",
"moved_to_account_banner.text": "Jou rekening {disabledAccount} is tans gedeaktiveer omdat jy na {movedToAccount} verhuis het.",
"navigation_bar.about": "Oor",
"navigation_bar.bookmarks": "Boekmerke",

View File

@ -186,7 +186,6 @@
"empty_column.hashtag": "No i hai cosa en este hashtag encara.",
"empty_column.home": "La tuya linia temporal ye vueda! Sigue a mas personas pa replenar-la. {suggestions}",
"empty_column.list": "No i hai cosa en esta lista encara. Quan miembros d'esta lista publiquen nuevos estatus, estes amaneixerán qui.",
"empty_column.lists": "No tiens garra lista. Quan en crees una, s'amostrará aquí.",
"empty_column.mutes": "Encara no has silenciau a garra usuario.",
"empty_column.notifications": "No tiens garra notificación encara. Interactúa con atros pa empecipiar una conversación.",
"empty_column.public": "No i hai cosa aquí! Escribe bella cosa publicament, u sigue usuarios d'atras instancias manualment pa emplir-lo",
@ -292,19 +291,11 @@
"lightbox.previous": "Anterior",
"limited_account_hint.action": "Amostrar perfil de totz modos",
"limited_account_hint.title": "Este perfil ha estau amagau per los moderadors de {domain}.",
"lists.account.add": "Anyadir a lista",
"lists.account.remove": "Sacar de lista",
"lists.delete": "Borrar lista",
"lists.edit": "Editar lista",
"lists.edit.submit": "Cambiar titol",
"lists.new.create": "Anyadir lista",
"lists.new.title_placeholder": "Titol d'a nueva lista",
"lists.replies_policy.followed": "Qualsequier usuario seguiu",
"lists.replies_policy.list": "Miembros d'a lista",
"lists.replies_policy.none": "Dengún",
"lists.replies_policy.title": "Amostrar respuestas a:",
"lists.search": "Buscar entre la chent a la quala sigues",
"lists.subheading": "Las tuyas listas",
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
"moved_to_account_banner.text": "La tuya cuenta {disabledAccount} ye actualment deshabilitada perque t'has mudau a {movedToAccount}.",
"navigation_bar.about": "Sobre",

View File

@ -269,7 +269,6 @@
"empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
"empty_column.home": "إنّ الخيط الزمني لصفحتك الرئيسة فارغ. قم بمتابعة المزيد من الناس كي يمتلأ.",
"empty_column.list": "هذه القائمة فارغة مؤقتا و لكن سوف تمتلئ تدريجيا عندما يبدأ الأعضاء المُنتَمين إليها بنشر منشورات.",
"empty_column.lists": "ليس عندك أية قائمة بعد. سوف تظهر قوائمك هنا إن قمت بإنشاء واحدة.",
"empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.",
"empty_column.notification_requests": "لا يوجد شيء هنا. عندما تتلقى إشعارات جديدة، سوف تظهر هنا وفقًا لإعداداتك.",
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
@ -425,20 +424,11 @@
"limited_account_hint.title": "تم إخفاء هذا الملف الشخصي من قبل مشرفي {domain}.",
"link_preview.author": "مِن {name}",
"link_preview.more_from_author": "المزيد من {name}",
"lists.account.add": "أضف إلى القائمة",
"lists.account.remove": "احذف من القائمة",
"lists.delete": "احذف القائمة",
"lists.edit": "عدّل القائمة",
"lists.edit.submit": "تعديل العنوان",
"lists.exclusive": "إخفاء هذه المنشورات من الخيط الرئيسي",
"lists.new.create": "إضافة قائمة",
"lists.new.title_placeholder": "عنوان القائمة الجديدة",
"lists.replies_policy.followed": "أي مستخدم متابَع",
"lists.replies_policy.list": "أعضاء القائمة",
"lists.replies_policy.none": "لا أحد",
"lists.replies_policy.title": "عرض الردود لـ:",
"lists.search": "إبحث في قائمة الحسابات التي تُتابِعها",
"lists.subheading": "قوائمك",
"load_pending": "{count, plural, one {# عنصر جديد} other {# عناصر جديدة}}",
"loading_indicator.label": "جاري التحميل…",
"media_gallery.hide": "إخفاء",

View File

@ -155,7 +155,6 @@
"empty_column.hashtag": "Entá nun hai nada con esta etiqueta.",
"empty_column.home": "¡La to llinia de tiempu ta balera! Sigui a cuentes pa enllenala.",
"empty_column.list": "Nun hai nada nesta llista. Cuando los perfiles d'esta llista espublicen artículos nuevos, apaecen equí.",
"empty_column.lists": "Nun tienes nenguna llista. Cuando crees dalguna, apaez equí.",
"empty_column.mutes": "Nun tienes nengún perfil colos avisos desactivaos.",
"empty_column.notifications": "Nun tienes nengún avisu. Cuando otros perfiles interactúen contigo, apaez equí.",
"empty_column.public": "¡Equí nun hai nada! Escribi daqué públicamente o sigui a perfiles d'otros sirvidores pa enllenar esta seición",
@ -260,15 +259,9 @@
"limited_account_hint.action": "Amosar el perfil de toes toes",
"lists.delete": "Desaniciar la llista",
"lists.edit": "Editar la llista",
"lists.edit.submit": "Camudar el títulu",
"lists.new.create": "Amestar la llista",
"lists.new.title_placeholder": "Títulu",
"lists.replies_policy.followed": "Cualesquier perfil siguíu",
"lists.replies_policy.list": "Perfiles de la llista",
"lists.replies_policy.none": "Naide",
"lists.replies_policy.title": "Amosar les rempuestes a:",
"lists.search": "Buscar ente los perfiles que sigues",
"lists.subheading": "Les tos llistes",
"load_pending": "{count, plural, one {# elementu nuevu} other {# elementos nuevos}}",
"navigation_bar.about": "Tocante a",
"navigation_bar.blocks": "Perfiles bloquiaos",

View File

@ -273,7 +273,6 @@
"empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.",
"empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}",
"empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.",
"empty_column.lists": "Як толькі вы створыце новы спіс ён будзе захоўвацца тут, але пакуль што тут пуста.",
"empty_column.mutes": "Вы яшчэ нікога не ігнаруеце.",
"empty_column.notification_requests": "Чысціня! Тут нічога няма. Калі вы будзеце атрымліваць новыя апавяшчэння, яны будуць з'яўляцца тут у адпаведнасці з вашымі наладамі.",
"empty_column.notifications": "У вас няма ніякіх апавяшчэнняў. Калі іншыя людзі ўзаемадзейнічаюць з вамі, вы ўбачыце гэта тут.",
@ -427,20 +426,11 @@
"link_preview.author": "Ад {name}",
"link_preview.more_from_author": "Больш ад {name}",
"link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
"lists.account.add": "Дадаць да спісу",
"lists.account.remove": "Выдаліць са спісу",
"lists.delete": "Выдаліць спіс",
"lists.edit": "Рэдагаваць спіс",
"lists.edit.submit": "Змяніць назву",
"lists.exclusive": "Схаваць гэтыя допісы з галоўнай старонкі",
"lists.new.create": "Дадаць спіс",
"lists.new.title_placeholder": "Назва новага спіса",
"lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся",
"lists.replies_policy.list": "Удзельнікі гэтага спісу",
"lists.replies_policy.none": "Нікога",
"lists.replies_policy.title": "Паказваць адказы:",
"lists.search": "Шукайце сярод людзей, на якіх Вы падпісаны",
"lists.subheading": "Вашыя спісы",
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
"loading_indicator.label": "Загрузка…",
"media_gallery.hide": "Схаваць",

View File

@ -139,13 +139,16 @@
"column.blocks": "Блокирани потребители",
"column.bookmarks": "Отметки",
"column.community": "Локален инфопоток",
"column.create_list": "Създаване на списък",
"column.direct": "Частни споменавания",
"column.directory": "Разглеждане на профили",
"column.domain_blocks": "Блокирани домейни",
"column.edit_list": "Промяна на списъка",
"column.favourites": "Любими",
"column.firehose": "Инфоканали на живо",
"column.follow_requests": "Заявки за последване",
"column.home": "Начало",
"column.list_members": "Управление на списъка с участници",
"column.lists": "Списъци",
"column.mutes": "Заглушени потребители",
"column.notifications": "Известия",
@ -289,7 +292,6 @@
"empty_column.hashtag": "Още няма нищо в този хаштаг.",
"empty_column.home": "Вашата начална часова ос е празна! Последвайте повече хора, за да я запълните. {suggestions}",
"empty_column.list": "Все още списъкът е празен. Членуващите на списъка, публикуващи нови публикации, ще се появят тук.",
"empty_column.lists": "Все още нямате списъци. Когато създадете такъв, той ще се покаже тук.",
"empty_column.mutes": "Още не сте заглушавали потребители.",
"empty_column.notification_requests": "Всичко е чисто! Тук няма нищо. Получавайки нови известия, те ще се появят тук според настройките ви.",
"empty_column.notifications": "Все още нямате известия. Взаимодействайте с другите, за да започнете разговора.",
@ -389,10 +391,16 @@
"ignore_notifications_modal.disclaimer": "Mastodon не може да осведоми потребители, че сте пренебрегнали известията им. Пренебрегването на известията няма да спре самите съобщения да не бъдат изпращани.",
"ignore_notifications_modal.filter_to_act_users": "Вие все още ще може да приемате, отхвърляте или докладвате потребители",
"ignore_notifications_modal.filter_to_avoid_confusion": "Прецеждането помага за избягване на възможно объркване",
"ignore_notifications_modal.ignore": "Пренебрегване на известията",
"ignore_notifications_modal.limited_accounts_title": "Пренебрегвате ли известията от модерирани акаунти?",
"ignore_notifications_modal.new_accounts_title": "Пренебрегвате ли известията от нови акаунти?",
"ignore_notifications_modal.not_followers_title": "Пренебрегвате ли известията от хора, които не са ви последвали?",
"ignore_notifications_modal.not_following_title": "Пренебрегвате ли известията от хора, които не сте последвали?",
"interaction_modal.description.favourite": "Имайки акаунт в Mastodon, може да сложите тази публикации в любими, за да позволите на автора да узнае, че я цените и да я запазите за по-късно.",
"interaction_modal.description.follow": "С акаунт в Mastodon може да последвате {name}, за да получавате публикациите от този акаунт в началния си инфоканал.",
"interaction_modal.description.reblog": "С акаунт в Mastodon може да подсилите тази публикация, за да я споделите с последователите си.",
"interaction_modal.description.reply": "С акаунт в Mastodon може да добавите отговор към тази публикация.",
"interaction_modal.description.vote": "Имайки акаунт в Mastodon, можете да гласувате в тази анкета.",
"interaction_modal.login.action": "Към началото",
"interaction_modal.login.prompt": "Домейнът на сървъра ви, примерно, mastodon.social",
"interaction_modal.no_account_yet": "Още не е в Мастодон?",
@ -452,20 +460,22 @@
"link_preview.author": "От {name}",
"link_preview.more_from_author": "Още от {name}",
"link_preview.shares": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
"lists.account.add": "Добавяне към списък",
"lists.account.remove": "Премахване от списъка",
"lists.create_list": "Създаване на списък",
"lists.delete": "Изтриване на списъка",
"lists.done": "Готово",
"lists.edit": "Промяна на списъка",
"lists.edit.submit": "Промяна на заглавие",
"lists.exclusive": "Скриване на тези публикации от началото",
"lists.new.create": "Добавяне на списък",
"lists.new.title_placeholder": "Ново заглавие на списъка",
"lists.find_users_to_add": "Намерете потребители за добавяне",
"lists.list_members": "Списък членуващи",
"lists.list_name": "Име на списък",
"lists.no_lists_yet": "Още няма списъци.",
"lists.no_members_yet": "Още няма членуващи.",
"lists.no_results_found": "Няма намерени резултати.",
"lists.remove_member": "Премахване",
"lists.replies_policy.followed": "Някой последван потребител",
"lists.replies_policy.list": "Членуващите в списъка",
"lists.replies_policy.none": "Никого",
"lists.replies_policy.title": "Показване на отговори на:",
"lists.search": "Търсене измежду последваните",
"lists.subheading": "Вашите списъци",
"lists.save": "Запазване",
"lists.search_placeholder": "Търсене сред, които сте последвали",
"load_pending": "{count, plural, one {# нов елемент} other {# нови елемента}}",
"loading_indicator.label": "Зареждане…",
"media_gallery.hide": "Скриване",
@ -596,6 +606,7 @@
"notifications.permission_required": "Известията на работния плот ги няма, щото няма дадено нужното позволение.",
"notifications.policy.accept": "Приемам",
"notifications.policy.accept_hint": "Показване в известия",
"notifications.policy.drop_hint": "Изпращане в празнотата, за да не се видим никога пак",
"notifications.policy.filter": "Филтър",
"notifications.policy.filter_limited_accounts_hint": "Ограничено от модераторите на сървъра",
"notifications.policy.filter_limited_accounts_title": "Модерирани акаунти",
@ -855,6 +866,10 @@
"upload_form.audio_description": "Опишете за хора, които са глухи или трудно чуват",
"upload_form.description": "Опишете за хора, които са слепи или имат слабо зрение",
"upload_form.drag_and_drop.instructions": "Натиснете интервал или enter, за да подберете мултимедийно прикачване. Провлачвайки, ползвайте клавишите със стрелки, за да премествате мултимедията във всяка дадена посока. Натиснете пак интервал или enter, за да се стовари мултимедийното прикачване в новото си положение или натиснете Esc за отмяна.",
"upload_form.drag_and_drop.on_drag_cancel": "Провлачването е отменено. Мултимедийното прикачване {item} е спуснато.",
"upload_form.drag_and_drop.on_drag_end": "Мултимедийното прикачване {item} е спуснато.",
"upload_form.drag_and_drop.on_drag_over": "Мултимедийното прикачване {item} е преместено.",
"upload_form.drag_and_drop.on_drag_start": "Избрано мултимедийно прикачване {item}.",
"upload_form.edit": "Редактиране",
"upload_form.thumbnail": "Промяна на миниобраза",
"upload_form.video_description": "Опишете за хора, които са глухи или трудно чуват, слепи или имат слабо зрение",

View File

@ -202,7 +202,6 @@
"empty_column.hashtag": "এই হেসটাগে এখনো কিছু নেই।",
"empty_column.home": "আপনার বাড়ির সময়রেখা এখনো খালি! {public} এ ঘুরে আসুন অথবা অনুসন্ধান বেবহার করে শুরু করতে পারেন এবং অন্য ব্যবহারকারীদের সাথে সাক্ষাৎ করতে পারেন।",
"empty_column.list": "এই তালিকাতে এখনো কিছু নেই. যখন এই তালিকায় থাকা ব্যবহারকারী নতুন কিছু লিখবে, সেগুলো এখানে পাওয়া যাবে।",
"empty_column.lists": "আপনার এখনো কোনো তালিকা তৈরী নেই। যদি বা যখন তৈরী করেন, সেগুলো এখানে পাওয়া যাবে।",
"empty_column.mutes": "আপনি এখনো কোনো ব্যবহারকারীকে নিঃশব্দ করেননি।",
"empty_column.notifications": "আপনার এখনো কোনো প্রজ্ঞাপন নেই। কথোপকথন শুরু করতে, অন্যদের সাথে মেলামেশা করতে পারেন।",
"empty_column.public": "এখানে এখনো কিছু নেই! প্রকাশ্য ভাবে কিছু লিখুন বা অন্য সার্ভার থেকে কাওকে অনুসরণ করে এই জায়গা ভরে ফেলুন",
@ -277,16 +276,9 @@
"lightbox.next": "পরবর্তী",
"lightbox.previous": "পূর্ববর্তী",
"link_preview.author": "{name} এর লিখা",
"lists.account.add": "তালিকাতে যুক্ত করতে",
"lists.account.remove": "তালিকা থেকে বাদ দিতে",
"lists.delete": "তালিকা মুছে ফেলতে",
"lists.edit": "তালিকা সম্পাদনা করতে",
"lists.edit.submit": "শিরোনাম সম্পাদনা করতে",
"lists.new.create": "তালিকাতে যুক্ত করতে",
"lists.new.title_placeholder": "তালিকার নতুন শিরোনাম দিতে",
"lists.replies_policy.none": "কেউ না",
"lists.search": "যাদের অনুসরণ করেন তাদের ভেতরে খুঁজুন",
"lists.subheading": "আপনার তালিকা",
"load_pending": "{count, plural, one {# নতুন জিনিস} other {# নতুন জিনিস}}",
"navigation_bar.about": "পরিচিতি",
"navigation_bar.blocks": "বন্ধ করা ব্যবহারকারী",

View File

@ -223,7 +223,6 @@
"empty_column.hashtag": "N'eus netra en hashtag-mañ c'hoazh.",
"empty_column.home": "Goullo eo ho red-amzer degemer! Kit da weladenniñ {public} pe implijit ar c'hlask evit kregiñ ganti ha kejañ gant implijer·ien·ezed all.",
"empty_column.list": "Goullo eo al listenn-mañ evit c'hoazh. Pa vo embannet toudoù nevez gant e izili e teuint war wel amañ.",
"empty_column.lists": "N'ho peus roll ebet c'hoazh. Pa vo krouet unan ganeoc'h e vo diskouezet amañ.",
"empty_column.mutes": "N'ho peus kuzhet implijer ebet c'hoazh.",
"empty_column.notifications": "N'ho peus kemenn ebet c'hoazh. Grit gant implijer·ezed·ien all evit loc'hañ ar gomz.",
"empty_column.public": "N'eus netra amañ! Skrivit un dra bennak foran pe heuilhit implijer·ien·ezed eus dafariadoù all evit leuniañ",
@ -346,19 +345,11 @@
"limited_account_hint.action": "Diskouez an aelad memes tra",
"limited_account_hint.title": "Kuzhet eo bet ar profil-mañ gant an evezhierien eus {domain}.",
"link_preview.author": "Gant {name}",
"lists.account.add": "Ouzhpennañ d'al listenn",
"lists.account.remove": "Lemel kuit eus al listenn",
"lists.delete": "Dilemel al listenn",
"lists.edit": "Kemmañ al listenn",
"lists.edit.submit": "Cheñch an titl",
"lists.new.create": "Ouzhpennañ ul listenn",
"lists.new.title_placeholder": "Titl nevez al listenn",
"lists.replies_policy.followed": "Pep implijer.ez heuliet",
"lists.replies_policy.list": "Izili ar roll",
"lists.replies_policy.none": "Den ebet",
"lists.replies_policy.title": "Diskouez ar respontoù:",
"lists.search": "Klask e-touez tud heuliet ganeoc'h",
"lists.subheading": "Ho listennoù",
"load_pending": "{count, plural, one {# dra nevez} other {# dra nevez}}",
"loading_indicator.label": "O kargañ…",
"navigation_bar.about": "Diwar-benn",

View File

@ -286,7 +286,6 @@
"empty_column.hashtag": "Encara no hi ha res en aquesta etiqueta.",
"empty_column.home": "La teva línia de temps és buida! Segueix més gent per a emplenar-la. {suggestions}",
"empty_column.list": "Encara no hi ha res en aquesta llista. Quan els membres facin nous tuts, apareixeran aquí.",
"empty_column.lists": "Encara no tens cap llista. Quan en facis una, apareixerà aquí.",
"empty_column.mutes": "Encara no has silenciat cap usuari.",
"empty_column.notification_requests": "Tot net, ja no hi ha res aquí! Quan rebeu notificacions noves, segons la vostra configuració, apareixeran aquí.",
"empty_column.notifications": "Encara no tens notificacions. Quan altre gent interactuï amb tu, les veuràs aquí.",
@ -459,20 +458,11 @@
"link_preview.author": "Per {name}",
"link_preview.more_from_author": "Més de {name}",
"link_preview.shares": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
"lists.account.add": "Afegeix a la llista",
"lists.account.remove": "Elimina de la llista",
"lists.delete": "Elimina la llista",
"lists.edit": "Edita la llista",
"lists.edit.submit": "Canvia el títol",
"lists.exclusive": "Amaga aquests tuts a Inici",
"lists.new.create": "Afegeix una llista",
"lists.new.title_placeholder": "Nou títol de la llista",
"lists.replies_policy.followed": "Qualsevol usuari que segueixis",
"lists.replies_policy.list": "Membres de la llista",
"lists.replies_policy.none": "Ningú",
"lists.replies_policy.title": "Mostra respostes a:",
"lists.search": "Cerca entre les persones que segueixes",
"lists.subheading": "Les teves llistes",
"load_pending": "{count, plural, one {# element nou} other {# elements nous}}",
"loading_indicator.label": "Es carrega…",
"media_gallery.hide": "Amaga",

View File

@ -221,7 +221,6 @@
"empty_column.hashtag": "هێشتا هیچ شتێک لەم هاشتاگەدا نییە.",
"empty_column.home": "تایم لاینی ماڵەوەت بەتاڵە! سەردانی {public} بکە یان گەڕان بەکاربێنە بۆ دەستپێکردن و بینینی بەکارهێنەرانی تر.",
"empty_column.list": "هێشتا هیچ شتێک لەم لیستەدا نییە. کاتێک ئەندامانی ئەم لیستە دەنگی نوێ بڵاودەکەن، لێرە دەردەکەون.",
"empty_column.lists": "تۆ هێشتا هیچ لیستت دروست نەکردووە، کاتێک دانەیەک دروست دەکەیت، لێرە پیشان دەدرێت.",
"empty_column.mutes": "تۆ هێشتا هیچ بەکارهێنەرێکت بێدەنگ نەکردووە.",
"empty_column.notifications": "تۆ هێشتا هیچ ئاگانامێکت نیە. چالاکی لەگەڵ کەسانی دیکە بکە بۆ دەستپێکردنی گفتوگۆکە.",
"empty_column.public": "لێرە هیچ نییە! شتێک بە ئاشکرا بنووسە(بەگشتی)، یان بە دەستی شوێن بەکارهێنەران بکەوە لە ڕاژەکانی ترەوە بۆ پڕکردنەوەی",
@ -338,19 +337,11 @@
"lightbox.previous": "پێشوو",
"limited_account_hint.action": "بەهەر حاڵ پڕۆفایلی پیشان بدە",
"limited_account_hint.title": "ئەم پرۆفایلە لەلایەن بەڕێوەبەرانی {domain} شاراوەتەوە.",
"lists.account.add": "زیادکردن بۆ لیست",
"lists.account.remove": "لابردن لە لیست",
"lists.delete": "سڕینەوەی لیست",
"lists.edit": "دەستکاری لیست",
"lists.edit.submit": "گۆڕینی ناونیشان",
"lists.new.create": "زیادکردنی لیست",
"lists.new.title_placeholder": "ناونیشانی لیستی نوێ",
"lists.replies_policy.followed": "هەر بەکارهێنەرێکی بەدواکەوتوو",
"lists.replies_policy.list": "ئەندامانی لیستەکە",
"lists.replies_policy.none": "هیچکەس",
"lists.replies_policy.title": "پیشاندانی وەڵامەکان بۆ:",
"lists.search": "بگەڕێ لەناو ئەو کەسانەی کە شوێنیان کەوتویت",
"lists.subheading": "لیستەکانت",
"load_pending": "{count, plural, one {# بەڕگەی نوێ} other {# بەڕگەی نوێ}}",
"moved_to_account_banner.text": "ئەکاونتەکەت {disabledAccount} لە ئێستادا لەکارخراوە چونکە تۆ چوویتە {movedToAccount}.",
"navigation_bar.about": "دەربارە",

View File

@ -132,7 +132,6 @@
"empty_column.hashtag": "Ùn c'hè ancu nunda quì.",
"empty_column.home": "A vostr'accolta hè viota! Pudete andà nant'à {public} o pruvà a ricerca per truvà parsone da siguità.",
"empty_column.list": "Ùn c'hè ancu nunda quì. Quandu membri di sta lista manderanu novi statuti, i vidarete quì.",
"empty_column.lists": "Ùn avete manc'una lista. Quandu farete una, sarà mustrata quì.",
"empty_column.mutes": "Per avà ùn avete manc'un utilizatore piattatu.",
"empty_column.notifications": "Ùn avete ancu nisuna nutificazione. Interact with others to start the conversation.",
"empty_column.public": "Ùn c'hè nunda quì! Scrivete qualcosa in pubblicu o seguitate utilizatori d'altri servori per empie a linea pubblica",
@ -198,19 +197,11 @@
"lightbox.close": "Chjudà",
"lightbox.next": "Siguente",
"lightbox.previous": "Pricidente",
"lists.account.add": "Aghjunghje à a lista",
"lists.account.remove": "Toglie di a lista",
"lists.delete": "Toglie a lista",
"lists.edit": "Mudificà a lista",
"lists.edit.submit": "Cambià u titulu",
"lists.new.create": "Aghjunghje",
"lists.new.title_placeholder": "Titulu di a lista",
"lists.replies_policy.followed": "Tutti i vostri abbunamenti",
"lists.replies_policy.list": "Membri di a lista",
"lists.replies_policy.none": "Nimu",
"lists.replies_policy.title": "Vede e risposte à:",
"lists.search": "Circà indè i vostr'abbunamenti",
"lists.subheading": "E vo liste",
"load_pending": "{count, plural, one {# entrata nova} other {# entrate nove}}",
"navigation_bar.blocks": "Utilizatori bluccati",
"navigation_bar.bookmarks": "Segnalibri",

View File

@ -267,7 +267,6 @@
"empty_column.hashtag": "Pod tímto hashtagem zde zatím nic není.",
"empty_column.home": "Vaše domovská časová osa je prázdná! Naplňte ji sledováním dalších lidí.",
"empty_column.list": "V tomto seznamu zatím nic není. Až nějaký člen z tohoto seznamu zveřejní nový příspěvek, objeví se zde.",
"empty_column.lists": "Zatím nemáte žádné seznamy. Až nějaký vytvoříte, zobrazí se zde.",
"empty_column.mutes": "Zatím jste neskryli žádného uživatele.",
"empty_column.notification_requests": "Vyčištěno! Nic tu není. Jakmile obdržíš nové notifikace, objeví se zde podle tvého nastavení.",
"empty_column.notifications": "Zatím nemáte žádná oznámení. Až s vámi někdo bude interagovat, uvidíte to zde.",
@ -415,20 +414,11 @@
"link_preview.author": "Podle {name}",
"link_preview.more_from_author": "Více od {name}",
"link_preview.shares": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"lists.account.add": "Přidat do seznamu",
"lists.account.remove": "Odebrat ze seznamu",
"lists.delete": "Smazat seznam",
"lists.edit": "Upravit seznam",
"lists.edit.submit": "Změnit název",
"lists.exclusive": "Skrýt tyto příspěvky z domovské stránky",
"lists.new.create": "Přidat seznam",
"lists.new.title_placeholder": "Název nového seznamu",
"lists.replies_policy.followed": "Sledovaným uživatelům",
"lists.replies_policy.list": "Členům seznamu",
"lists.replies_policy.none": "Nikomu",
"lists.replies_policy.title": "Odpovědi zobrazovat:",
"lists.search": "Hledejte mezi lidmi, které sledujete",
"lists.subheading": "Vaše seznamy",
"load_pending": "{count, plural, one {# nová položka} few {# nové položky} many {# nových položek} other {# nových položek}}",
"loading_indicator.label": "Načítání…",
"moved_to_account_banner.text": "Váš účet {disabledAccount} je momentálně deaktivován, protože jste se přesunul/a na {movedToAccount}.",

View File

@ -292,7 +292,6 @@
"empty_column.hashtag": "Nid oes dim ar yr hashnod hwn eto.",
"empty_column.home": "Mae eich ffrwd gartref yn wag! Dilynwch fwy o bobl i'w llenwi.",
"empty_column.list": "Does dim yn y rhestr yma eto. Pan fydd aelodau'r rhestr yn cyhoeddi postiad newydd, mi fydd yn ymddangos yma.",
"empty_column.lists": "Nid oes gennych unrhyw restrau eto. Pan fyddwch yn creu un, mi fydd yn ymddangos yma.",
"empty_column.mutes": "Nid ydych wedi tewi unrhyw ddefnyddwyr eto.",
"empty_column.notification_requests": "Dim i boeni amdano! Does dim byd yma. Pan fyddwch yn derbyn hysbysiadau newydd, byddan nhw'n ymddangos yma yn ôl eich gosodiadau.",
"empty_column.notifications": "Nid oes gennych unrhyw hysbysiadau eto. Rhyngweithiwch ag eraill i ddechrau'r sgwrs.",
@ -465,20 +464,11 @@
"link_preview.author": "Gan {name}",
"link_preview.more_from_author": "Mwy gan {name}",
"link_preview.shares": "{count, plural, one {{counter} postiad } two {{counter} bostiad } few {{counter} postiad} many {{counter} postiad} other {{counter} postiad}}",
"lists.account.add": "Ychwanegu at restr",
"lists.account.remove": "Tynnu o'r rhestr",
"lists.delete": "Dileu rhestr",
"lists.edit": "Golygu rhestr",
"lists.edit.submit": "Newid teitl",
"lists.exclusive": "Cuddio'r postiadau hyn o'r ffrwd gartref",
"lists.new.create": "Ychwanegu rhestr",
"lists.new.title_placeholder": "Teitl rhestr newydd",
"lists.replies_policy.followed": "Unrhyw ddefnyddiwr sy'n cael ei ddilyn",
"lists.replies_policy.list": "Aelodau'r rhestr",
"lists.replies_policy.none": "Neb",
"lists.replies_policy.title": "Dangos atebion i:",
"lists.search": "Chwilio ymysg pobl rydych yn eu dilyn",
"lists.subheading": "Eich rhestrau",
"load_pending": "{count, plural, one {# eitem newydd} other {# eitem newydd}}",
"loading_indicator.label": "Yn llwytho…",
"media_gallery.hide": "Cuddio",

View File

@ -140,13 +140,16 @@
"column.blocks": "Blokerede brugere",
"column.bookmarks": "Bogmærker",
"column.community": "Lokal tidslinje",
"column.create_list": "Opret liste",
"column.direct": "Private omtaler",
"column.directory": "Tjek profiler",
"column.domain_blocks": "Blokerede domæner",
"column.edit_list": "Redigér liste",
"column.favourites": "Favoritter",
"column.firehose": "Live feeds",
"column.follow_requests": "Følgeanmodninger",
"column.home": "Hjem",
"column.list_members": "Håndtér listemedlemmer",
"column.lists": "Lister",
"column.mutes": "Skjulte brugere (mutede)",
"column.notifications": "Notifikationer",
@ -292,7 +295,6 @@
"empty_column.hashtag": "Der er intet med dette hashtag endnu.",
"empty_column.home": "Din hjemmetidslinje er tom! Følg nogle personer, for at udfylde den. {suggestions}",
"empty_column.list": "Der er ikke noget på denne liste endnu. Når medlemmer af listen udgiver nye indlæg vil de fremgå hér.",
"empty_column.lists": "Du har endnu ingen lister. Når du opretter én, vil den fremgå hér.",
"empty_column.mutes": "Du har endnu ikke skjult (muted) nogle brugere.",
"empty_column.notification_requests": "Alt er klar! Der er intet her. Når der modtages nye notifikationer, fremgår de her jf. dine indstillinger.",
"empty_column.notifications": "Du har endnu ingen notifikationer. Når andre interagerer med dig, vil det fremgå hér.",
@ -465,20 +467,31 @@
"link_preview.author": "Af {name}",
"link_preview.more_from_author": "Mere fra {name}",
"link_preview.shares": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
"lists.account.add": "Føj til liste",
"lists.account.remove": "Fjern fra liste",
"lists.add_member": "Tilføj",
"lists.add_to_list": "Føj til liste",
"lists.add_to_lists": "Føj {name} til lister",
"lists.create": "Opret",
"lists.create_a_list_to_organize": "Opret en ny liste til organisering af hjemmefeed",
"lists.create_list": "Opret liste",
"lists.delete": "Slet liste",
"lists.done": "Færdig",
"lists.edit": "Redigér liste",
"lists.edit.submit": "Skift titel",
"lists.exclusive": "Skjul disse indlæg hjemmefra",
"lists.new.create": "Tilføj liste",
"lists.new.title_placeholder": "Ny listetitel",
"lists.exclusive": "Skjul medlemmer i Hjem",
"lists.exclusive_hint": "Er nogen er på denne liste, skjul personen i hjemme-feeds for at undgå at se vedkommendes indlæg to gange.",
"lists.find_users_to_add": "Find brugere at tilføje",
"lists.list_members_count": "{count, plural, one {# medlem} other {# medlemmer}}",
"lists.list_name": "Listetitel",
"lists.new_list_name": "Ny listetitel",
"lists.no_lists_yet": "Ingen lister endnu.",
"lists.no_members_yet": "Ingen medlemmer endnu.",
"lists.no_results_found": "Ingen resultater fundet.",
"lists.remove_member": "Fjern",
"lists.replies_policy.followed": "Enhver bruger, der følges",
"lists.replies_policy.list": "Listemedlemmer",
"lists.replies_policy.none": "Ingen",
"lists.replies_policy.title": "Vis svar til:",
"lists.search": "Søg blandt personer, som følges",
"lists.subheading": "Dine lister",
"lists.save": "Gem",
"lists.search_placeholder": "Søg efter folk, man følger",
"lists.show_replies_to": "Medtag svar fra listemedlemmer til",
"load_pending": "{count, plural, one {# nyt emne} other {# nye emner}}",
"loading_indicator.label": "Indlæser…",
"media_gallery.hide": "Skjul",

View File

@ -140,13 +140,16 @@
"column.blocks": "Blockierte Profile",
"column.bookmarks": "Lesezeichen",
"column.community": "Lokale Timeline",
"column.create_list": "Liste erstellen",
"column.direct": "Private Erwähnungen",
"column.directory": "Profile durchsuchen",
"column.domain_blocks": "Blockierte Domains",
"column.edit_list": "Liste bearbeiten",
"column.favourites": "Favoriten",
"column.firehose": "Live-Feeds",
"column.follow_requests": "Follower-Anfragen",
"column.home": "Startseite",
"column.list_members": "Listenmitglieder verwalten",
"column.lists": "Listen",
"column.mutes": "Stummgeschaltete Profile",
"column.notifications": "Benachrichtigungen",
@ -292,7 +295,6 @@
"empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
"empty_column.home": "Die Timeline deiner Startseite ist leer! Folge mehr Leuten, um sie zu füllen.",
"empty_column.list": "Diese Liste ist derzeit leer. Wenn Konten auf dieser Liste neue Beiträge veröffentlichen, werden sie hier erscheinen.",
"empty_column.lists": "Du hast noch keine Listen. Sobald du eine anlegst, wird sie hier erscheinen.",
"empty_column.mutes": "Du hast keine Profile stummgeschaltet.",
"empty_column.notification_requests": "Alles klar! Hier gibt es nichts. Wenn Sie neue Mitteilungen erhalten, werden diese entsprechend Ihren Einstellungen hier angezeigt.",
"empty_column.notifications": "Du hast noch keine Benachrichtigungen. Sobald andere Personen mit dir interagieren, wirst du hier darüber informiert.",
@ -465,20 +467,32 @@
"link_preview.author": "Von {name}",
"link_preview.more_from_author": "Mehr von {name}",
"link_preview.shares": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
"lists.account.add": "Zur Liste hinzufügen",
"lists.account.remove": "Von der Liste entfernen",
"lists.add_member": "Hinzufügen",
"lists.add_to_list": "Zur Liste hinzufügen",
"lists.add_to_lists": "{name} zu Listen hinzufügen",
"lists.create": "Erstellen",
"lists.create_a_list_to_organize": "Erstelle eine neue Liste, um deine Startseite zu organisieren",
"lists.create_list": "Liste erstellen",
"lists.delete": "Liste löschen",
"lists.done": "Fertig",
"lists.edit": "Liste bearbeiten",
"lists.edit.submit": "Titel ändern",
"lists.exclusive": "Diese Beiträge in der Startseite ausblenden",
"lists.new.create": "Neue Liste erstellen",
"lists.new.title_placeholder": "Titel der neuen Liste",
"lists.exclusive": "Mitglieder auf der Startseite ausblenden",
"lists.exclusive_hint": "Profile, die sich auf dieser Liste befinden, werden nicht auf deiner Startseite angezeigt, damit deren Beiträge nicht doppelt erscheinen.",
"lists.find_users_to_add": "Suche nach Profilen, um sie hinzuzufügen",
"lists.list_members": "Listenmitglieder",
"lists.list_members_count": "{count, plural, one {# Mitglied} other {# Mitglieder}}",
"lists.list_name": "Titel der Liste",
"lists.new_list_name": "Neuer Listentitel",
"lists.no_lists_yet": "Noch keine Listen vorhanden.",
"lists.no_members_yet": "Keine Mitglieder vorhanden.",
"lists.no_results_found": "Keine Suchergebnisse.",
"lists.remove_member": "Entfernen",
"lists.replies_policy.followed": "Alle folgenden Profile",
"lists.replies_policy.list": "Mitglieder der Liste",
"lists.replies_policy.none": "Niemanden",
"lists.replies_policy.title": "Antworten anzeigen für:",
"lists.search": "Suche nach Leuten, denen du folgst",
"lists.subheading": "Deine Listen",
"lists.save": "Speichern",
"lists.search_placeholder": "Nach Profilen suchen, denen du folgst",
"lists.show_replies_to": "Antworten von Listenmitgliedern anzeigen für ",
"load_pending": "{count, plural, one {# neuer Beitrag} other {# neue Beiträge}}",
"loading_indicator.label": "Wird geladen …",
"media_gallery.hide": "Ausblenden",
@ -486,12 +500,12 @@
"mute_modal.hide_from_notifications": "Benachrichtigungen ausblenden",
"mute_modal.hide_options": "Einstellungen ausblenden",
"mute_modal.indefinite": "Bis ich die Stummschaltung aufhebe",
"mute_modal.show_options": "Einstellungen anzeigen",
"mute_modal.show_options": "Optionen anzeigen",
"mute_modal.they_can_mention_and_follow": "Das Profil wird dich weiterhin erwähnen und dir folgen können, aber du wirst davon nichts sehen.",
"mute_modal.they_wont_know": "Es wird nicht erkennbar sein, dass dieses Profil stummgeschaltet wurde.",
"mute_modal.they_wont_know": "Das Profil wird nicht erkennen können, dass du es stummgeschaltet hast.",
"mute_modal.title": "Profil stummschalten?",
"mute_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.",
"mute_modal.you_wont_see_posts": "Deine Beiträge können weiterhin angesehen werden, aber du wirst deren Beiträge nicht mehr sehen.",
"mute_modal.you_wont_see_posts": "Deine Beiträge können von diesem stummgeschalteten Profil weiterhin gesehen werden, aber du wirst dessen Beiträge nicht mehr sehen.",
"navigation_bar.about": "Über",
"navigation_bar.administration": "Administration",
"navigation_bar.advanced_interface": "Im erweiterten Webinterface öffnen",

View File

@ -291,7 +291,6 @@
"empty_column.hashtag": "Δεν υπάρχει ακόμα κάτι για αυτή την ετικέτα.",
"empty_column.home": "Η τοπική σου ροή είναι κενή! Πήγαινε στο {public} ή κάνε αναζήτηση για να ξεκινήσεις και να γνωρίσεις άλλους χρήστες.",
"empty_column.list": "Δεν υπάρχει τίποτα σε αυτή τη λίστα ακόμα. Όταν τα μέλη της δημοσιεύσουν νέες καταστάσεις, θα εμφανιστούν εδώ.",
"empty_column.lists": "Δεν έχεις καμία λίστα ακόμα. Μόλις φτιάξεις μια, θα εμφανιστεί εδώ.",
"empty_column.mutes": "Δεν έχεις κανένα χρήστη σε σίγαση ακόμα.",
"empty_column.notification_requests": "Όλα καθαρά! Δεν υπάρχει τίποτα εδώ. Όταν λαμβάνεις νέες ειδοποιήσεις, αυτές θα εμφανίζονται εδώ σύμφωνα με τις ρυθμίσεις σου.",
"empty_column.notifications": "Δεν έχεις ειδοποιήσεις ακόμα. Όταν άλλα άτομα αλληλεπιδράσουν μαζί σου, θα το δεις εδώ.",
@ -464,20 +463,11 @@
"link_preview.author": "Από {name}",
"link_preview.more_from_author": "Περισσότερα από {name}",
"link_preview.shares": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}}",
"lists.account.add": "Πρόσθεσε στη λίστα",
"lists.account.remove": "Βγάλε από τη λίστα",
"lists.delete": "Διαγραφή λίστας",
"lists.edit": "Επεξεργασία λίστας",
"lists.edit.submit": "Αλλαγή τίτλου",
"lists.exclusive": "Απόκρυψη αυτών των αναρτήσεων από την αρχική",
"lists.new.create": "Προσθήκη λίστας",
"lists.new.title_placeholder": "Τίτλος νέας λίστα",
"lists.replies_policy.followed": "Οποιοσδήποτε χρήστης που ακολουθείς",
"lists.replies_policy.list": "Μέλη της λίστας",
"lists.replies_policy.none": "Κανένας",
"lists.replies_policy.title": "Εμφάνιση απαντήσεων σε:",
"lists.search": "Αναζήτησε μεταξύ των ανθρώπων που ακουλουθείς",
"lists.subheading": "Οι λίστες σου",
"load_pending": "{count, plural, one {# νέο στοιχείο} other {# νέα στοιχεία}}",
"loading_indicator.label": "Φόρτωση…",
"media_gallery.hide": "Απόκρυψη",

View File

@ -87,6 +87,25 @@
"alert.unexpected.title": "Oops!",
"alt_text_badge.title": "Alt text",
"announcement.announcement": "Announcement",
"annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "The lurker",
"annual_report.summary.archetype.oracle": "The oracle",
"annual_report.summary.archetype.pollster": "The pollster",
"annual_report.summary.archetype.replier": "The social butterfly",
"annual_report.summary.followers.followers": "followers",
"annual_report.summary.followers.total": "{count} total",
"annual_report.summary.here_it_is": "Here is your {year} in review:",
"annual_report.summary.highlighted_post.by_favourites": "most favourited post",
"annual_report.summary.highlighted_post.by_reblogs": "most boosted post",
"annual_report.summary.highlighted_post.by_replies": "post with the most replies",
"annual_report.summary.highlighted_post.possessive": "{name}'s",
"annual_report.summary.most_used_app.most_used_app": "most used app",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag",
"annual_report.summary.most_used_hashtag.none": "None",
"annual_report.summary.new_posts.new_posts": "new posts",
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
"annual_report.summary.thanks": "Thanks for being part of Mastodon!",
"attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio",
"block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.",
@ -158,6 +177,7 @@
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.multiple": "Multiple choice",
"compose_form.poll.option_placeholder": "Option {number}",
"compose_form.poll.single": "Single choice",
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.poll.type": "Style",
@ -196,6 +216,7 @@
"confirmations.unfollow.title": "Unfollow user?",
"content_warning.hide": "Hide post",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",
"conversation.delete": "Delete conversation",
"conversation.mark_as_read": "Mark as read",
"conversation.open": "View conversation",
@ -271,7 +292,6 @@
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
"empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notification_requests": "All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
@ -304,6 +324,7 @@
"filter_modal.select_filter.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a post",
"filter_warning.matches_filter": "Matches filter \"<span>{title}</span>\"",
"filtered_notifications_banner.pending_requests": "From {count, plural, =0 {no one} one {one person} other {# people}} you may know",
"filtered_notifications_banner.title": "Filtered notifications",
"firehose.all": "All",
@ -383,6 +404,7 @@
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
"interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.",
"interaction_modal.description.reply": "With an account on Mastodon, you can respond to this post.",
"interaction_modal.description.vote": "With an account on Mastodon, you can vote in this poll.",
"interaction_modal.login.action": "Take me home",
"interaction_modal.login.prompt": "Domain of your home server, e.g. mastodon.social",
"interaction_modal.no_account_yet": "Not on Mastodon?",
@ -394,6 +416,7 @@
"interaction_modal.title.follow": "Follow {name}",
"interaction_modal.title.reblog": "Boost {name}'s post",
"interaction_modal.title.reply": "Reply to {name}'s post",
"interaction_modal.title.vote": "Vote in {name}'s poll",
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
@ -441,20 +464,11 @@
"link_preview.author": "By {name}",
"link_preview.more_from_author": "More from {name}",
"link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.exclusive": "Hide these posts from home",
"lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title",
"lists.replies_policy.followed": "Any followed user",
"lists.replies_policy.list": "Members of the list",
"lists.replies_policy.none": "No one",
"lists.replies_policy.title": "Show replies to:",
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"loading_indicator.label": "Loading…",
"media_gallery.hide": "Hide",
@ -503,9 +517,11 @@
"notification.admin.report_statuses_other": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.admin.sign_up.name_and_others": "{name} and {count, plural, one {# other} other {# others}} signed up",
"notification.annual_report.message": "Your {year} #Wrapstodon awaits! Unveil your year's highlights and memorable moments on Mastodon!",
"notification.favourite": "{name} favourited your post",
"notification.favourite.name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a> favourited your post",
"notification.follow": "{name} followed you",
"notification.follow.name_and_others": "{name} and <a>{count, plural, one {# other} other {# others}}</a> followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.follow_request.name_and_others": "{name} and {count, plural, one {# other} other {# others}} has requested to follow you",
"notification.label.mention": "Mention",
@ -513,6 +529,7 @@
"notification.label.private_reply": "Private reply",
"notification.label.reply": "Reply",
"notification.mention": "Mention",
"notification.mentioned_you": "{name} mentioned you",
"notification.moderation-warning.learn_more": "Learn more",
"notification.moderation_warning": "You have received a moderation warning",
"notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.",
@ -563,6 +580,7 @@
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.follow_request": "New follow requests:",
"notifications.column_settings.group": "Group",
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",

View File

@ -140,13 +140,16 @@
"column.blocks": "Blocked users",
"column.bookmarks": "Bookmarks",
"column.community": "Local timeline",
"column.create_list": "Create list",
"column.direct": "Private mentions",
"column.directory": "Browse profiles",
"column.domain_blocks": "Blocked domains",
"column.edit_list": "Edit list",
"column.favourites": "Favorites",
"column.firehose": "Live feeds",
"column.follow_requests": "Follow requests",
"column.home": "Home",
"column.list_members": "Manage list members",
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
@ -292,7 +295,6 @@
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
"empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notification_requests": "All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
@ -465,20 +467,32 @@
"link_preview.author": "By {name}",
"link_preview.more_from_author": "More from {name}",
"link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.add_member": "Add",
"lists.add_to_list": "Add to list",
"lists.add_to_lists": "Add {name} to lists",
"lists.create": "Create",
"lists.create_a_list_to_organize": "Create a new list to organize your Home feed",
"lists.create_list": "Create list",
"lists.delete": "Delete list",
"lists.done": "Done",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.exclusive": "Hide these posts from home",
"lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title",
"lists.exclusive": "Hide members in Home",
"lists.exclusive_hint": "If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.",
"lists.find_users_to_add": "Find users to add",
"lists.list_members": "List members",
"lists.list_members_count": "{count, plural, one {# member} other {# members}}",
"lists.list_name": "List name",
"lists.new_list_name": "New list name",
"lists.no_lists_yet": "No lists yet.",
"lists.no_members_yet": "No members yet.",
"lists.no_results_found": "No results found.",
"lists.remove_member": "Remove",
"lists.replies_policy.followed": "Any followed user",
"lists.replies_policy.list": "Members of the list",
"lists.replies_policy.none": "No one",
"lists.replies_policy.title": "Show replies to:",
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"lists.save": "Save",
"lists.search_placeholder": "Search people you follow",
"lists.show_replies_to": "Include replies from list members to",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"loading_indicator.label": "Loading…",
"media_gallery.hide": "Hide",

View File

@ -280,7 +280,6 @@
"empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.",
"empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
"empty_column.list": "Ankoraŭ estas nenio en ĉi tiu listo. Kiam membroj de ĉi tiu listo afiŝos novajn afiŝojn, ili aperos ĉi tie.",
"empty_column.lists": "Vi ankoraŭ ne havas liston. Kiam vi kreos iun, ĝi aperos ĉi tie.",
"empty_column.mutes": "Vi ne ankoraŭ silentigis iun uzanton.",
"empty_column.notification_requests": "Ĉio klara! Estas nenio tie ĉi. Kiam vi ricevas novajn sciigojn, ili aperos ĉi tie laŭ viaj agordoj.",
"empty_column.notifications": "Vi ankoraŭ ne havas sciigojn. Interagu kun aliaj por komenci konversacion.",
@ -453,20 +452,11 @@
"link_preview.author": "De {name}",
"link_preview.more_from_author": "Pli de {name}",
"link_preview.shares": "{count, plural, one {{counter} afiŝo} other {{counter} afiŝoj}}",
"lists.account.add": "Aldoni al la listo",
"lists.account.remove": "Forigi de la listo",
"lists.delete": "Forigi la liston",
"lists.edit": "Redakti la liston",
"lists.edit.submit": "Ŝanĝi titolon",
"lists.exclusive": "Kaŝi ĉi tiujn afiŝojn de hejmo",
"lists.new.create": "Aldoni liston",
"lists.new.title_placeholder": "Titolo de la nova listo",
"lists.replies_policy.followed": "Iu sekvanta uzanto",
"lists.replies_policy.list": "Membroj de la listo",
"lists.replies_policy.none": "Neniu",
"lists.replies_policy.title": "Montri respondojn al:",
"lists.search": "Serĉi inter la homoj, kiujn vi sekvas",
"lists.subheading": "Viaj listoj",
"load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}",
"loading_indicator.label": "Ŝargado…",
"media_gallery.hide": "Kaŝi",

View File

@ -292,7 +292,6 @@
"empty_column.hashtag": "Todavía no hay nada con esta etiqueta.",
"empty_column.home": "¡Tu línea temporal principal está vacía! Seguí a más cuentas para llenarla.",
"empty_column.list": "Todavía no hay nada en esta lista. Cuando miembros de esta lista envíen nuevos mensaje, se mostrarán acá.",
"empty_column.lists": "Todavía no tenés ninguna lista. Cuando creés una, se mostrará acá.",
"empty_column.mutes": "Todavía no silenciaste a ningún usuario.",
"empty_column.notification_requests": "¡Todo limpio! No hay nada acá. Cuando recibás nuevas notificaciones, aparecerán acá, acorde a tu configuración.",
"empty_column.notifications": "Todavía no tenés ninguna notificación. Cuando otras cuentas interactúen con vos, vas a ver la notificación acá.",
@ -465,20 +464,11 @@
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Más de {name}",
"link_preview.shares": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
"lists.account.add": "Agregar a lista",
"lists.account.remove": "Quitar de lista",
"lists.delete": "Eliminar lista",
"lists.edit": "Editar lista",
"lists.edit.submit": "Cambiar título",
"lists.exclusive": "Ocultar estos mensajes del inicio",
"lists.new.create": "Agregar lista",
"lists.new.title_placeholder": "Título de nueva lista",
"lists.replies_policy.followed": "Cualquier cuenta seguida",
"lists.replies_policy.list": "Miembros de la lista",
"lists.replies_policy.none": "Nadie",
"lists.replies_policy.title": "Mostrar respuestas a:",
"lists.search": "Buscar entre la gente que seguís",
"lists.subheading": "Tus listas",
"load_pending": "{count, plural, one {# elemento nuevo} other {# elementos nuevos}}",
"loading_indicator.label": "Cargando…",
"media_gallery.hide": "Ocultar",

View File

@ -292,7 +292,6 @@
"empty_column.hashtag": "No hay nada en esta etiqueta aún.",
"empty_column.home": "No estás siguiendo a nadie aún. Visita {public} o haz búsquedas para empezar y conocer gente nueva.",
"empty_column.list": "No hay nada en esta lista aún. Cuando miembros de esta lista publiquen nuevos estatus, estos aparecerán qui.",
"empty_column.lists": "No tienes ninguna lista. cuando crees una, se mostrará aquí.",
"empty_column.mutes": "Aún no has silenciado a ningún usuario.",
"empty_column.notification_requests": "¡Todo limpio! No hay nada aquí. Cuando recibas nuevas notificaciones, aparecerán aquí conforme a tu configuración.",
"empty_column.notifications": "No tienes ninguna notificación aún. Interactúa con otros para empezar una conversación.",
@ -465,20 +464,11 @@
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Más de {name}",
"link_preview.shares": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"lists.account.add": "Añadir a lista",
"lists.account.remove": "Quitar de lista",
"lists.delete": "Borrar lista",
"lists.edit": "Editar lista",
"lists.edit.submit": "Cambiar título",
"lists.exclusive": "Ocultar estas publicaciones en inicio",
"lists.new.create": "Añadir lista",
"lists.new.title_placeholder": "Título de la nueva lista",
"lists.replies_policy.followed": "Cualquier usuario seguido",
"lists.replies_policy.list": "Miembros de la lista",
"lists.replies_policy.none": "Nadie",
"lists.replies_policy.title": "Mostrar respuestas a:",
"lists.search": "Buscar entre la gente a la que sigues",
"lists.subheading": "Tus listas",
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
"loading_indicator.label": "Cargando…",
"media_gallery.hide": "Ocultar",

View File

@ -292,7 +292,6 @@
"empty_column.hashtag": "No hay nada en esta etiqueta todavía.",
"empty_column.home": "¡Tu línea temporal está vacía! Sigue a más personas para rellenarla.",
"empty_column.list": "Aún no hay nada en esta lista. Cuando los miembros de esta lista publiquen nuevos estados, estos aparecerán aquí.",
"empty_column.lists": "No tienes ninguna lista. Cuando crees una, se mostrará aquí.",
"empty_column.mutes": "Aún no has silenciado a ningún usuario.",
"empty_column.notification_requests": "¡Todo limpio! No hay nada aquí. Cuando recibas nuevas notificaciones, aparecerán aquí conforme a tu configuración.",
"empty_column.notifications": "Aún no tienes ninguna notificación. Cuando otras personas interactúen contigo, aparecerán aquí.",
@ -465,20 +464,11 @@
"link_preview.author": "Por {name}",
"link_preview.more_from_author": "Más de {name}",
"link_preview.shares": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"lists.account.add": "Añadir a lista",
"lists.account.remove": "Quitar de lista",
"lists.delete": "Borrar lista",
"lists.edit": "Editar lista",
"lists.edit.submit": "Cambiar título",
"lists.exclusive": "Ocultar estas publicaciones de inicio",
"lists.new.create": "Añadir lista",
"lists.new.title_placeholder": "Título de la nueva lista",
"lists.replies_policy.followed": "Cualquier usuario seguido",
"lists.replies_policy.list": "Miembros de la lista",
"lists.replies_policy.none": "Nadie",
"lists.replies_policy.title": "Mostrar respuestas a:",
"lists.search": "Buscar entre las personas a las que sigues",
"lists.subheading": "Tus listas",
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
"loading_indicator.label": "Cargando…",
"media_gallery.hide": "Ocultar",

View File

@ -273,7 +273,6 @@
"empty_column.hashtag": "Selle sildi all ei ole ühtegi postitust.",
"empty_column.home": "Su koduajajoon on tühi. Jälgi rohkemaid inimesi, et seda täita {suggestions}",
"empty_column.list": "Siin loetelus pole veel midagi. Kui loetelu liikmed teevad uusi postitusi, näed neid siin.",
"empty_column.lists": "Pole veel ühtegi nimekirja. Kui lood mõne, näed neid siin.",
"empty_column.mutes": "Sa pole veel ühtegi kasutajat vaigistanud.",
"empty_column.notification_requests": "Kõik tühi! Siin pole mitte midagi. Kui saad uusi teavitusi, ilmuvad need siin vastavalt sinu seadistustele.",
"empty_column.notifications": "Ei ole veel teateid. Kui keegi suhtleb sinuga, näed seda siin.",
@ -446,20 +445,11 @@
"link_preview.author": "{name} poolt",
"link_preview.more_from_author": "Veel kasutajalt {name}",
"link_preview.shares": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
"lists.account.add": "Lisa nimekirja",
"lists.account.remove": "Eemalda nimekirjast",
"lists.delete": "Kustuta nimekiri",
"lists.edit": "Muuda nimekirja",
"lists.edit.submit": "Pealkirja muutmine",
"lists.exclusive": "Peida koduvaatest need postitused",
"lists.new.create": "Lisa nimekiri",
"lists.new.title_placeholder": "Uue nimekirja pealkiri",
"lists.replies_policy.followed": "Igalt jälgitud kasutajalt",
"lists.replies_policy.list": "Listi liikmetelt",
"lists.replies_policy.none": "Mitte kelleltki",
"lists.replies_policy.title": "Näita vastuseid nendele:",
"lists.search": "Otsi enda jälgitavate inimeste hulgast",
"lists.subheading": "Sinu nimekirjad",
"load_pending": "{count, plural, one {# uus kirje} other {# uut kirjet}}",
"loading_indicator.label": "Laadimine…",
"media_gallery.hide": "Peida",

View File

@ -269,7 +269,6 @@
"empty_column.hashtag": "Ez dago ezer traola honetan oraindik.",
"empty_column.home": "Zure hasierako denbora-lerroa hutsik dago! Jarraitu jende gehiago betetzeko.",
"empty_column.list": "Ez dago ezer zerrenda honetan. Zerrenda honetako kideek bidalketa berriak argitaratzean, hemen agertuko dira.",
"empty_column.lists": "Ez duzu zerrendarik oraindik. Baten bat sortzen duzunean hemen agertuko da.",
"empty_column.mutes": "Ez duzu erabiltzailerik mututu oraindik.",
"empty_column.notification_requests": "Garbi-garbi! Ezertxo ere ez hemen. Jakinarazpenak jasotzen dituzunean, hemen agertuko dira zure ezarpenen arabera.",
"empty_column.notifications": "Ez duzu jakinarazpenik oraindik. Jarri besteekin harremanetan elkarrizketa abiatzeko.",
@ -437,20 +436,11 @@
"link_preview.author": "Egilea: {name}",
"link_preview.more_from_author": "{name} erabiltzaileaz gehiago jakin",
"link_preview.shares": "{count, plural, one {{counter} bidalketa} other {{counter} bidalketa}}",
"lists.account.add": "Gehitu zerrendara",
"lists.account.remove": "Kendu zerrendatik",
"lists.delete": "Ezabatu zerrenda",
"lists.edit": "Editatu zerrenda",
"lists.edit.submit": "Aldatu izenburua",
"lists.exclusive": "Ezkutatu argitalpen hauek hasieratik",
"lists.new.create": "Gehitu zerrenda",
"lists.new.title_placeholder": "Zerrenda berriaren izena",
"lists.replies_policy.followed": "Jarraitutako edozein erabiltzaile",
"lists.replies_policy.list": "Zerrendako kideak",
"lists.replies_policy.none": "Bat ere ez",
"lists.replies_policy.title": "Erakutsi erantzunak:",
"lists.search": "Bilatu jarraitzen dituzun pertsonen artean",
"lists.subheading": "Zure zerrendak",
"load_pending": "{count, plural, one {elementu berri #} other {# elementu berri}}",
"loading_indicator.label": "Kargatzen…",
"media_gallery.hide": "Ezkutatu",

View File

@ -272,7 +272,6 @@
"empty_column.hashtag": "هنوز هیچ چیزی در این برچسب نیست.",
"empty_column.home": "خط زمانی خانگیتان خالی است! برای پر کردنش، افراد بیشتری را پی بگیرید. {suggestions}",
"empty_column.list": "هنوز چیزی در این سیاهه نیست. هنگامی که اعضایش فرسته‌های جدیدی بفرستند، این‌جا ظاهر خواهند شد.",
"empty_column.lists": "هنوز هیچ سیاهه‌ای ندارید. هنگامی که یکی بسازید، این‌جا نشان داده خواهد شد.",
"empty_column.mutes": "هنوز هیچ کاربری را خموش نکرده‌اید.",
"empty_column.notifications": "هنوز هیچ آگاهی‌آی ندارید. هنگامی که دیگران با شما برهم‌کنش داشته باشند،‌این‌حا خواهید دیدش.",
"empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران کارسازهای دیگر را پی‌گیری کنید تا این‌جا پُر شود",
@ -437,20 +436,11 @@
"link_preview.author": "از {name}",
"link_preview.more_from_author": "بیش‌تر از {name}",
"link_preview.shares": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
"lists.account.add": "افزودن به سیاهه",
"lists.account.remove": "برداشتن از سیاهه",
"lists.delete": "حذف سیاهه",
"lists.edit": "ویرایش سیاهه",
"lists.edit.submit": "تغییر عنوان",
"lists.exclusive": "نهفتن این فرسته‌ها از خانه",
"lists.new.create": "افزودن سیاهه",
"lists.new.title_placeholder": "عنوان سیاههٔ جدید",
"lists.replies_policy.followed": "هر کاربر پی‌گرفته",
"lists.replies_policy.list": "اعضای سیاهه",
"lists.replies_policy.none": "هیچ کدام",
"lists.replies_policy.title": "نمایش پاسخ‌ها به:",
"lists.search": "جست‌وجو بین کسانی که پی‌گرفته‌اید",
"lists.subheading": "سیاهه‌هایتان",
"load_pending": "{count, plural, one {# مورد جدید} other {# مورد جدید}}",
"loading_indicator.label": "در حال بارگذاری…",
"media_gallery.hide": "نهفتن",

Some files were not shown because too many files have changed in this diff Show More