Merge remote-tracking branch 'soapbox/develop' into hide-profile-tabs
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
38066799e3
126 changed files with 1523 additions and 1636 deletions
|
@ -174,9 +174,9 @@ const excludeTypesFromFilter = (filter: string) => {
|
|||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
};
|
||||
|
||||
const noOp = () => {};
|
||||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const expandNotifications = ({ maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => any = noOp) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return dispatch(noOp);
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST';
|
|||
export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS';
|
||||
export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL';
|
||||
|
||||
export const obtainOAuthToken = (params: Record<string, string>, baseURL?: string) =>
|
||||
export const obtainOAuthToken = (params: Record<string, string | undefined>, baseURL?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
|
||||
return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => {
|
||||
|
|
|
@ -140,9 +140,15 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
|
|||
const replaceHomeTimeline = (
|
||||
accountId: string | null,
|
||||
{ maxId }: Record<string, any> = {},
|
||||
done?: () => void,
|
||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
dispatch({ type: TIMELINE_REPLACE, accountId });
|
||||
dispatch(expandHomeTimeline({ accountId, maxId }, () => dispatch(insertSuggestionsIntoTimeline())));
|
||||
dispatch(expandHomeTimeline({ accountId, maxId }, () => {
|
||||
dispatch(insertSuggestionsIntoTimeline());
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import Portal from '@reach/portal';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import AutosuggestEmoji, { Emoji } from './autosuggest_emoji';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const textAtCursorMatchesToken = (str: string, caretPosition: number) => {
|
||||
let word;
|
||||
|
||||
const left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
|
@ -36,25 +36,28 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
|||
}
|
||||
};
|
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
interface IAutosuggesteTextarea {
|
||||
id?: string,
|
||||
value: string,
|
||||
suggestions: ImmutableList<string>,
|
||||
disabled: boolean,
|
||||
placeholder: string,
|
||||
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
|
||||
onSuggestionsClearRequested: () => void,
|
||||
onSuggestionsFetchRequested: (token: string | number) => void,
|
||||
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
|
||||
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>,
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>,
|
||||
onPaste: (files: FileList) => void,
|
||||
autoFocus: boolean,
|
||||
onFocus: () => void,
|
||||
onBlur?: () => void,
|
||||
condensed?: boolean,
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
condensed: PropTypes.bool,
|
||||
};
|
||||
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
|
||||
|
||||
textarea: HTMLTextAreaElement | null = null;
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
|
@ -68,7 +71,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
|
@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
|
@ -91,7 +94,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
if (e.which === 229 || e.isComposing) {
|
||||
if (e.which === 229 || (e as any).isComposing) {
|
||||
// Ignore key events during text composition
|
||||
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
|
||||
return;
|
||||
|
@ -100,7 +103,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (suggestions.size === 0 || suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
|
@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
this.textarea?.focus();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
|
||||
// Skip updating when only the lastToken changes so the
|
||||
// cursor doesn't jump around due to re-rendering unnecessarily
|
||||
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
|
||||
|
@ -172,29 +175,29 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
if (lastTokenUpdated && !valueUpdated) {
|
||||
return false;
|
||||
} else {
|
||||
return super.shouldComponentUpdate(nextProps, nextState);
|
||||
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
|
||||
const { suggestions } = this.props;
|
||||
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea = (c) => {
|
||||
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
onPaste = (e) => {
|
||||
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
renderSuggestion = (suggestion: string | Emoji, i: number) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
|
@ -212,7 +215,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
return (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
tabIndex={0}
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
|
@ -272,7 +275,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
style={style as any}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
</label>
|
||||
|
@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default AutosuggestTextarea;
|
|
@ -18,7 +18,7 @@ let id = 0;
|
|||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||
text: string | JSX.Element,
|
||||
text: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
newTab?: boolean,
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* ForkAwesomeIcon: renders a ForkAwesome icon.
|
||||
* Full list: https://forkaweso.me/Fork-Awesome/icons/
|
||||
* @module soapbox/components/fork_awesome_icon
|
||||
* @see soapbox/components/icon
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default class ForkAwesomeIcon extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { id, className, fixedWidth, ...other } = this.props;
|
||||
|
||||
// Use the Fork Awesome retweet icon, but change its alt
|
||||
// tag. There is a common adblocker rule which hides elements with
|
||||
// alt='retweet' unless the domain is twitter.com. This should
|
||||
// change what screenreaders call it as well.
|
||||
const alt = (id === 'retweet') ? 'repost' : id;
|
||||
|
||||
return (
|
||||
<i
|
||||
role='img'
|
||||
alt={alt}
|
||||
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
34
app/soapbox/components/fork_awesome_icon.tsx
Normal file
34
app/soapbox/components/fork_awesome_icon.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* ForkAwesomeIcon: renders a ForkAwesome icon.
|
||||
* Full list: https://forkaweso.me/Fork-Awesome/icons/
|
||||
* @module soapbox/components/fork_awesome_icon
|
||||
* @see soapbox/components/icon
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> {
|
||||
id: string,
|
||||
className?: string,
|
||||
fixedWidth?: boolean,
|
||||
}
|
||||
|
||||
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => {
|
||||
// Use the Fork Awesome retweet icon, but change its alt
|
||||
// tag. There is a common adblocker rule which hides elements with
|
||||
// alt='retweet' unless the domain is twitter.com. This should
|
||||
// change what screenreaders call it as well.
|
||||
// const alt = (id === 'retweet') ? 'repost' : id;
|
||||
|
||||
return (
|
||||
<i
|
||||
role='img'
|
||||
// alt={alt}
|
||||
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForkAwesomeIcon;
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* Icon: abstract icon class that can render icons from multiple sets.
|
||||
* @module soapbox/components/icon
|
||||
* @see soapbox/components/fork_awesome_icon
|
||||
* @see soapbox/components/svg_icon
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import ForkAwesomeIcon from './fork_awesome_icon';
|
||||
import SvgIcon from './svg_icon';
|
||||
|
||||
export default class Icon extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string,
|
||||
src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
className: PropTypes.string,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { id, src, fixedWidth, ...rest } = this.props;
|
||||
|
||||
if (src) {
|
||||
return <SvgIcon src={src} {...rest} />;
|
||||
} else {
|
||||
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
27
app/soapbox/components/icon.tsx
Normal file
27
app/soapbox/components/icon.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Icon: abstract icon class that can render icons from multiple sets.
|
||||
* @module soapbox/components/icon
|
||||
* @see soapbox/components/fork_awesome_icon
|
||||
* @see soapbox/components/svg_icon
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork_awesome_icon';
|
||||
import SvgIcon, { ISvgIcon } from './svg_icon';
|
||||
|
||||
export type IIcon = IForkAwesomeIcon | ISvgIcon;
|
||||
|
||||
const Icon: React.FC<IIcon> = (props) => {
|
||||
if ((props as ISvgIcon).src) {
|
||||
const { src, ...rest } = (props as ISvgIcon);
|
||||
|
||||
return <SvgIcon src={src} {...rest} />;
|
||||
} else {
|
||||
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
|
||||
|
||||
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default Icon;
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import Icon, { IIcon } from 'soapbox/components/icon';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
|||
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Icon id={icon} {...rest} />
|
||||
<Icon id={icon} {...rest as IIcon} />
|
||||
|
||||
{count > 0 && (
|
||||
<i className='absolute -top-2 -right-2'>
|
||||
|
|
|
@ -3,7 +3,6 @@ import React, { useEffect, useRef, useMemo, useCallback } from 'react';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso';
|
||||
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import LoadMore from './load_more';
|
||||
|
@ -63,7 +62,10 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
|||
placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
|
||||
/** Number of placeholders to render while loading. */
|
||||
placeholderCount?: number,
|
||||
/** Pull to refresh callback. */
|
||||
/**
|
||||
* Pull to refresh callback.
|
||||
* @deprecated Put a PTR around the component instead.
|
||||
*/
|
||||
onRefresh?: () => Promise<any>,
|
||||
/** Extra class names on the Virtuoso element. */
|
||||
className?: string,
|
||||
|
@ -244,20 +246,12 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
/>
|
||||
);
|
||||
|
||||
/** Conditionally render inner elements. */
|
||||
const renderBody = (): JSX.Element => {
|
||||
if (isEmpty) {
|
||||
return renderEmpty();
|
||||
} else {
|
||||
return renderFeed();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
{renderBody()}
|
||||
</PullToRefresh>
|
||||
);
|
||||
// Conditionally render inner elements.
|
||||
if (isEmpty) {
|
||||
return renderEmpty();
|
||||
} else {
|
||||
return renderFeed();
|
||||
}
|
||||
});
|
||||
|
||||
export default ScrollableList;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
|
@ -11,8 +11,20 @@ import SidebarNavigationLink from './sidebar-navigation-link';
|
|||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' },
|
||||
all: { id: 'tabs_bar.all', defaultMessage: 'All' },
|
||||
fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' },
|
||||
});
|
||||
|
||||
/** Desktop sidebar with links to different views in the app. */
|
||||
const SidebarNavigation = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
const account = useOwnAccount();
|
||||
|
@ -30,7 +42,7 @@ const SidebarNavigation = () => {
|
|||
if (account.locked || followRequestsCount > 0) {
|
||||
menu.push({
|
||||
to: '/follow_requests',
|
||||
text: <FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' />,
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
icon: require('@tabler/icons/user-plus.svg'),
|
||||
count: followRequestsCount,
|
||||
});
|
||||
|
@ -39,7 +51,7 @@ const SidebarNavigation = () => {
|
|||
if (features.bookmarks) {
|
||||
menu.push({
|
||||
to: '/bookmarks',
|
||||
text: <FormattedMessage id='column.bookmarks' defaultMessage='Bookmarks' />,
|
||||
text: intl.formatMessage(messages.bookmarks),
|
||||
icon: require('@tabler/icons/bookmark.svg'),
|
||||
});
|
||||
}
|
||||
|
@ -47,7 +59,7 @@ const SidebarNavigation = () => {
|
|||
if (features.lists) {
|
||||
menu.push({
|
||||
to: '/lists',
|
||||
text: <FormattedMessage id='column.lists' defaultMessage='Lists' />,
|
||||
text: intl.formatMessage(messages.lists),
|
||||
icon: require('@tabler/icons/list.svg'),
|
||||
});
|
||||
}
|
||||
|
@ -56,7 +68,7 @@ const SidebarNavigation = () => {
|
|||
menu.push({
|
||||
to: '/developers',
|
||||
icon: require('@tabler/icons/code.svg'),
|
||||
text: <FormattedMessage id='navigation.developers' defaultMessage='Developers' />,
|
||||
text: intl.formatMessage(messages.developers),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -64,7 +76,7 @@ const SidebarNavigation = () => {
|
|||
menu.push({
|
||||
to: '/soapbox/admin',
|
||||
icon: require('@tabler/icons/dashboard.svg'),
|
||||
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
|
||||
text: intl.formatMessage(messages.dashboard),
|
||||
count: dashboardCount,
|
||||
});
|
||||
}
|
||||
|
@ -78,7 +90,7 @@ const SidebarNavigation = () => {
|
|||
menu.push({
|
||||
to: '/timeline/local',
|
||||
icon: features.federating ? require('@tabler/icons/users.svg') : require('@tabler/icons/world.svg'),
|
||||
text: features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />,
|
||||
text: features.federating ? instance.title : intl.formatMessage(messages.all),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -86,7 +98,7 @@ const SidebarNavigation = () => {
|
|||
menu.push({
|
||||
to: '/timeline/fediverse',
|
||||
icon: require('icons/fediverse.svg'),
|
||||
text: <FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />,
|
||||
text: intl.formatMessage(messages.fediverse),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
|||
/** ID of the timeline in Redux. */
|
||||
timelineId?: string,
|
||||
/** Whether to display a gap or border between statuses in the list. */
|
||||
divideType: 'space' | 'border',
|
||||
divideType?: 'space' | 'border',
|
||||
}
|
||||
|
||||
/** Feed of statuses, built atop ScrollableList. */
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
import throttle from 'lodash/throttle';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
|
||||
import { CardHeader, CardTitle } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { settings: Settings }) => {
|
||||
return {
|
||||
onOpenSettings() {
|
||||
dispatch(openModal('COMPONENT', { component: Settings }));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(undefined, mapDispatchToProps)
|
||||
@injectIntl
|
||||
@withRouter
|
||||
class SubNavigation extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
message: PropTypes.string,
|
||||
settings: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
|
||||
onOpenSettings: PropTypes.func.isRequired,
|
||||
history: PropTypes.object,
|
||||
}
|
||||
|
||||
state = {
|
||||
scrolled: false,
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.props.history.push('/');
|
||||
} else {
|
||||
this.props.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
handleBackKeyUp = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleClick();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.attachScrollListener();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.detachScrollListener();
|
||||
}
|
||||
|
||||
attachScrollListener() {
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
detachScrollListener() {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (this.node) {
|
||||
const { offsetTop } = this.node;
|
||||
|
||||
if (offsetTop > 0) {
|
||||
this.setState({ scrolled: true });
|
||||
} else {
|
||||
this.setState({ scrolled: false });
|
||||
}
|
||||
}
|
||||
}, 150, { trailing: true });
|
||||
|
||||
handleOpenSettings = () => {
|
||||
this.props.onOpenSettings();
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, message } = this.props;
|
||||
|
||||
return (
|
||||
<CardHeader
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
onBackClick={this.handleBackClick}
|
||||
>
|
||||
<CardTitle title={message} />
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
83
app/soapbox/components/sub_navigation.tsx
Normal file
83
app/soapbox/components/sub_navigation.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
// import throttle from 'lodash/throttle';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
// import { connect } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
// import { openModal } from 'soapbox/actions/modals';
|
||||
// import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import { CardHeader, CardTitle } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
});
|
||||
|
||||
interface ISubNavigation {
|
||||
message: String,
|
||||
settings?: React.ComponentType,
|
||||
}
|
||||
|
||||
const SubNavigation: React.FC<ISubNavigation> = ({ message }) => {
|
||||
const intl = useIntl();
|
||||
// const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
// const ref = useRef(null);
|
||||
|
||||
// const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
// const onOpenSettings = () => {
|
||||
// dispatch(openModal('COMPONENT', { component: Settings }));
|
||||
// };
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
history.push('/');
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
// const handleBackKeyUp = (e) => {
|
||||
// if (e.key === 'Enter') {
|
||||
// handleClick();
|
||||
// }
|
||||
// }
|
||||
|
||||
// const handleOpenSettings = () => {
|
||||
// onOpenSettings();
|
||||
// }
|
||||
|
||||
// useEffect(() => {
|
||||
// const handleScroll = throttle(() => {
|
||||
// if (this.node) {
|
||||
// const { offsetTop } = this.node;
|
||||
|
||||
// if (offsetTop > 0) {
|
||||
// setScrolled(true);
|
||||
// } else {
|
||||
// setScrolled(false);
|
||||
// }
|
||||
// }
|
||||
// }, 150, { trailing: true });
|
||||
|
||||
// window.addEventListener('scroll', handleScroll);
|
||||
|
||||
// return () => {
|
||||
// window.removeEventListener('scroll', handleScroll);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<CardHeader
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
onBackClick={handleBackClick}
|
||||
>
|
||||
<CardTitle title={message} />
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubNavigation;
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* SvgIcon: abstact component to render SVG icons.
|
||||
* @module soapbox/components/svg_icon
|
||||
* @see soapbox/components/icon
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
||||
|
||||
export default class SvgIcon extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
alt: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { src, className, alt, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('svg-icon', className)}
|
||||
{...other}
|
||||
>
|
||||
<InlineSVG src={src} title={alt} loader={<></>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
29
app/soapbox/components/svg_icon.tsx
Normal file
29
app/soapbox/components/svg_icon.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* SvgIcon: abstact component to render SVG icons.
|
||||
* @module soapbox/components/svg_icon
|
||||
* @see soapbox/components/icon
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
||||
|
||||
export interface ISvgIcon extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src: string,
|
||||
id?: string,
|
||||
alt?: string,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, className, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames('svg-icon', className)}
|
||||
{...rest}
|
||||
>
|
||||
<InlineSVG src={src} title={alt} loader={<></>} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SvgIcon;
|
|
@ -21,7 +21,7 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
|
|||
<HotKeys handlers={handlers}>
|
||||
<div className='p-9 flex items-center justify-center sm:rounded-xl bg-gray-100 border border-solid border-gray-200 dark:bg-slate-900 dark:border-slate-700 focusable' tabIndex={0}>
|
||||
<Text>
|
||||
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts is unavailable.' />
|
||||
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
|
||||
</Text>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -18,6 +17,8 @@ export interface IColumn {
|
|||
withHeader?: boolean,
|
||||
/** Extra class name for top <div> element. */
|
||||
className?: string,
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
|
|
|
@ -27,6 +27,7 @@ export {
|
|||
MenuList,
|
||||
} from './menu/menu';
|
||||
export { default as Modal } from './modal/modal';
|
||||
export { default as PhoneInput } from './phone-input/phone-input';
|
||||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as Select } from './select/select';
|
||||
export { default as Spinner } from './spinner/spinner';
|
||||
|
|
|
@ -20,7 +20,7 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
|
|||
className?: string,
|
||||
/** Extra class names for the outer <div> element. */
|
||||
outerClassName?: string,
|
||||
/** URL to the svg icon. */
|
||||
/** URL to the svg icon. Cannot be used with addon. */
|
||||
icon?: string,
|
||||
/** Internal input name. */
|
||||
name?: string,
|
||||
|
@ -31,9 +31,11 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
|
|||
/** Change event handler for the input. */
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
|
||||
/** HTML input type. */
|
||||
type: 'text' | 'number' | 'email' | 'tel' | 'password',
|
||||
type?: 'text' | 'number' | 'email' | 'tel' | 'password',
|
||||
/** Whether to display the input in red. */
|
||||
hasError?: boolean,
|
||||
/** An element to display as prefix to input. Cannot be used with icon. */
|
||||
addon?: React.ReactElement,
|
||||
}
|
||||
|
||||
/** Form input element. */
|
||||
|
@ -41,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
(props, ref) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { type = 'text', icon, className, outerClassName, hasError, ...filteredProps } = props;
|
||||
const { type = 'text', icon, className, outerClassName, hasError, addon, ...filteredProps } = props;
|
||||
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
|
||||
|
@ -59,6 +61,12 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{addon ? (
|
||||
<div className='absolute inset-y-0 left-0 flex items-center'>
|
||||
{addon}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
{...filteredProps}
|
||||
type={revealed ? 'text' : type}
|
||||
|
@ -69,6 +77,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
'pr-7': isPassword,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
'pl-8': typeof icon !== 'undefined',
|
||||
'pl-16': typeof addon !== 'undefined',
|
||||
}, className)}
|
||||
/>
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
|
||||
import { COUNTRY_CODES, CountryCode } from 'soapbox/utils/phone';
|
||||
|
||||
interface ICountryCodeDropdown {
|
||||
countryCode: CountryCode,
|
||||
onChange(countryCode: CountryCode): void,
|
||||
}
|
||||
|
||||
/** Dropdown menu to select a country code. */
|
||||
const CountryCodeDropdown: React.FC<ICountryCodeDropdown> = ({ countryCode, onChange }) => {
|
||||
return (
|
||||
<select
|
||||
value={countryCode}
|
||||
className='h-full py-0 pl-3 pr-7 text-base bg-transparent border-transparent focus:outline-none focus:ring-primary-500 dark:text-white sm:text-sm rounded-md'
|
||||
onChange={(event) => onChange(event.target.value as any)}
|
||||
>
|
||||
{COUNTRY_CODES.map((code) => (
|
||||
<option value={code} key={code}>+{code}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountryCodeDropdown;
|
81
app/soapbox/components/ui/phone-input/phone-input.tsx
Normal file
81
app/soapbox/components/ui/phone-input/phone-input.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { CountryCode } from 'soapbox/utils/phone';
|
||||
|
||||
import Input from '../input/input';
|
||||
|
||||
import CountryCodeDropdown from './country-code-dropdown';
|
||||
|
||||
interface IPhoneInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'required' | 'autoFocus'> {
|
||||
/** E164 phone number. */
|
||||
value?: string,
|
||||
/** Change handler which receives the E164 phone string. */
|
||||
onChange?: (phone: string | undefined) => void,
|
||||
/** Country code that's selected on mount. */
|
||||
defaultCountryCode?: CountryCode,
|
||||
}
|
||||
|
||||
/** Internationalized phone input with country code picker. */
|
||||
const PhoneInput: React.FC<IPhoneInput> = (props) => {
|
||||
const { value, onChange, defaultCountryCode = '1', ...rest } = props;
|
||||
|
||||
const [countryCode, setCountryCode] = useState<CountryCode>(defaultCountryCode);
|
||||
const [nationalNumber, setNationalNumber] = useState<string>('');
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
// HACK: AsYouType is not meant to be used this way. But it works!
|
||||
const asYouType = new AsYouType({ defaultCallingCode: countryCode });
|
||||
const formatted = asYouType.input(target.value);
|
||||
|
||||
// If the new value is the same as before, we might be backspacing,
|
||||
// so use the actual event value instead of the formatted value.
|
||||
if (formatted === nationalNumber && target.value !== nationalNumber) {
|
||||
setNationalNumber(target.value);
|
||||
} else {
|
||||
setNationalNumber(formatted);
|
||||
}
|
||||
};
|
||||
|
||||
// When the internal state changes, update the external state.
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
try {
|
||||
const opts = { defaultCallingCode: countryCode, extract: false } as any;
|
||||
const result = parsePhoneNumber(nationalNumber, opts);
|
||||
|
||||
// Throw if the number is invalid, but catch it below.
|
||||
// We'll only ever call `onChange` with a valid E164 string or `undefined`.
|
||||
if (!result.isPossible()) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
onChange(result.format('E.164'));
|
||||
} catch (e) {
|
||||
// The value returned is always a valid E164 string.
|
||||
// If it's not valid, it'll return undefined.
|
||||
onChange(undefined);
|
||||
}
|
||||
}
|
||||
}, [countryCode, nationalNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
handleChange({ target: { value: nationalNumber } } as any);
|
||||
}, [countryCode, nationalNumber]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
onChange={handleChange}
|
||||
value={nationalNumber}
|
||||
addon={
|
||||
<CountryCodeDropdown
|
||||
countryCode={countryCode}
|
||||
onChange={setCountryCode}
|
||||
/>
|
||||
}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneInput;
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows'> {
|
||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
|
||||
/** Put the cursor into the input on mount. */
|
||||
autoFocus?: boolean,
|
||||
/** The initial text in the input. */
|
||||
|
|
|
@ -84,7 +84,7 @@ const Aliases = () => {
|
|||
<Text tag='span'>{alias}</Text>
|
||||
</div>
|
||||
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<Icon className='pr-1.5 text-lg' id='times' size={40} />
|
||||
<Icon className='pr-1.5 text-lg' id='times' />
|
||||
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
|
||||
</div>
|
||||
</HStack>
|
||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import StatusList from 'soapbox/components/status_list';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
|
@ -39,16 +40,17 @@ const Bookmarks: React.FC = () => {
|
|||
<div className='px-4 pt-4 sm:p-0'>
|
||||
<SubNavigation message={intl.formatMessage(messages.heading)} />
|
||||
</div>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='bookmarked_statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
onRefresh={handleRefresh}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType='space'
|
||||
/>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='bookmarked_statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType='space'
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { connectCommunityStream } from 'soapbox/actions/streaming';
|
||||
import { expandCommunityTimeline } from 'soapbox/actions/timelines';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useSettings } from 'soapbox/hooks';
|
||||
|
@ -44,14 +45,15 @@ const CommunityTimeline = () => {
|
|||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
|
||||
<Timeline
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
divideType='space'
|
||||
/>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Timeline
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
divideType='space'
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,231 +0,0 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createApp } from 'soapbox/actions/apps';
|
||||
import { obtainOAuthToken } from 'soapbox/actions/oauth';
|
||||
import { Button, Form, FormActions, FormGroup, Input, Stack, Text, Textarea } from 'soapbox/components/ui';
|
||||
import Column from 'soapbox/features/ui/components/column';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.app_create', defaultMessage: 'Create app' },
|
||||
namePlaceholder: { id: 'app_create.name_placeholder', defaultMessage: 'e.g. \'Soapbox\'' },
|
||||
scopesPlaceholder: { id: 'app_create.scopes_placeholder', defaultMessage: 'e.g. \'read write follow\'' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
return {
|
||||
account,
|
||||
defaultScopes: features.scopes,
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class CreateApp extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
defaultScopes: PropTypes.string,
|
||||
}
|
||||
|
||||
initialState = () => {
|
||||
return {
|
||||
params: ImmutableMap({
|
||||
client_name: '',
|
||||
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
scopes: '',
|
||||
website: '',
|
||||
}),
|
||||
app: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
state = this.initialState()
|
||||
|
||||
handleCreateApp = () => {
|
||||
const { dispatch, account } = this.props;
|
||||
const { params } = this.state;
|
||||
const baseURL = getBaseURL(account);
|
||||
|
||||
return dispatch(createApp(params.toJS(), baseURL))
|
||||
.then(app => this.setState({ app }));
|
||||
}
|
||||
|
||||
handleCreateToken = () => {
|
||||
const { dispatch, account } = this.props;
|
||||
const { app, params: appParams } = this.state;
|
||||
const baseURL = getBaseURL(account);
|
||||
|
||||
const tokenParams = {
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
redirect_uri: appParams.get('redirect_uri'),
|
||||
grant_type: 'client_credentials',
|
||||
scope: appParams.get('scopes'),
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(tokenParams, baseURL))
|
||||
.then(token => this.setState({ token }));
|
||||
}
|
||||
|
||||
handleSubmit = e => {
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
this.handleCreateApp()
|
||||
.then(this.handleCreateToken)
|
||||
.then(() => {
|
||||
this.scrollToTop();
|
||||
this.setState({ isLoading: false });
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
setParam = (key, value) => {
|
||||
const { params } = this.state;
|
||||
const newParams = params.set(key, value);
|
||||
|
||||
this.setState({ params: newParams });
|
||||
}
|
||||
|
||||
handleParamChange = key => {
|
||||
return e => {
|
||||
this.setParam(key, e.target.value);
|
||||
};
|
||||
}
|
||||
|
||||
resetState = () => {
|
||||
this.setState(this.initialState());
|
||||
}
|
||||
|
||||
handleReset = e => {
|
||||
this.resetState();
|
||||
this.scrollToTop();
|
||||
}
|
||||
|
||||
scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
renderResults = () => {
|
||||
const { intl } = this.props;
|
||||
const { app, token } = this.state;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form>
|
||||
<Stack>
|
||||
<Text size='lg' weight='medium'>
|
||||
<FormattedMessage id='app_create.results.explanation_title' defaultMessage='App created successfully' />
|
||||
</Text>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='app_create.results.explanation_text'
|
||||
defaultMessage='You created a new app and token! Please copy the credentials somewhere; you will not see them again after navigating away from this page.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.results.app_label' defaultMessage='App' />}>
|
||||
<Textarea
|
||||
value={JSON.stringify(app, null, 2)}
|
||||
rows={10}
|
||||
readOnly
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.results.token_label' defaultMessage='OAuth token' />}>
|
||||
<Textarea
|
||||
value={JSON.stringify(token, null, 2)}
|
||||
rows={10}
|
||||
readOnly
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='button' onClick={this.handleReset}>
|
||||
<FormattedMessage id='app_create.restart' defaultMessage='Create another' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
const { params, app, token, isLoading } = this.state;
|
||||
|
||||
if (app && token) {
|
||||
return this.renderResults();
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.name_label' defaultMessage='App name' />}>
|
||||
<Input
|
||||
placeholder={intl.formatMessage(messages.namePlaceholder)}
|
||||
onChange={this.handleParamChange('client_name')}
|
||||
value={params.get('client_name')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.website_label' defaultMessage='Website' />}>
|
||||
<Input
|
||||
placeholder='https://soapbox.pub'
|
||||
onChange={this.handleParamChange('website')}
|
||||
value={params.get('website')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.redirect_uri_label' defaultMessage='Redirect URIs' />}>
|
||||
<Input
|
||||
placeholder='https://example.com'
|
||||
onChange={this.handleParamChange('redirect_uris')}
|
||||
value={params.get('redirect_uris')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.scopes_label' defaultMessage='Scopes' />}>
|
||||
<Input
|
||||
placeholder={intl.formatMessage(messages.scopesPlaceholder)}
|
||||
onChange={this.handleParamChange('scopes')}
|
||||
value={params.get('scopes')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='submit' disabled={isLoading}>
|
||||
<FormattedMessage id='app_create.submit' defaultMessage='Create app' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
201
app/soapbox/features/developers/apps/create.tsx
Normal file
201
app/soapbox/features/developers/apps/create.tsx
Normal file
|
@ -0,0 +1,201 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import { createApp } from 'soapbox/actions/apps';
|
||||
import { obtainOAuthToken } from 'soapbox/actions/oauth';
|
||||
import { Button, Form, FormActions, FormGroup, Input, Stack, Text, Textarea } from 'soapbox/components/ui';
|
||||
import Column from 'soapbox/features/ui/components/column';
|
||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.app_create', defaultMessage: 'Create app' },
|
||||
namePlaceholder: { id: 'app_create.name_placeholder', defaultMessage: 'e.g. \'Soapbox\'' },
|
||||
scopesPlaceholder: { id: 'app_create.scopes_placeholder', defaultMessage: 'e.g. \'read write follow\'' },
|
||||
});
|
||||
|
||||
const BLANK_PARAMS = {
|
||||
client_name: '',
|
||||
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
scopes: '',
|
||||
website: '',
|
||||
};
|
||||
|
||||
type Params = typeof BLANK_PARAMS;
|
||||
|
||||
const CreateApp: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useOwnAccount();
|
||||
|
||||
const [app, setApp] = useState<Record<string, any> | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [params, setParams] = useState<Params>(BLANK_PARAMS);
|
||||
|
||||
const handleCreateApp = () => {
|
||||
const baseURL = getBaseURL(account!);
|
||||
|
||||
return dispatch(createApp(params, baseURL))
|
||||
.then(app => {
|
||||
setApp(app);
|
||||
return app;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateToken = (app: Record<string, string>) => {
|
||||
const baseURL = getBaseURL(account!);
|
||||
|
||||
const tokenParams = {
|
||||
client_id: app!.client_id,
|
||||
client_secret: app!.client_secret,
|
||||
redirect_uri: params.redirect_uris,
|
||||
grant_type: 'client_credentials',
|
||||
scope: params.scopes,
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(tokenParams, baseURL))
|
||||
.then(setToken);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setLoading(true);
|
||||
|
||||
handleCreateApp()
|
||||
.then(handleCreateToken)
|
||||
.then(() => {
|
||||
scrollToTop();
|
||||
setLoading(false);
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const setParam = (key: string, value: string) => {
|
||||
setParams({ ...params, [key]: value });
|
||||
};
|
||||
|
||||
const handleParamChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return e => {
|
||||
setParam(key, e.target.value);
|
||||
};
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setApp(null);
|
||||
setToken(null);
|
||||
setLoading(false);
|
||||
setParams(BLANK_PARAMS);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetState();
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const scrollToTop = (): void => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const renderResults = () => {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form>
|
||||
<Stack>
|
||||
<Text size='lg' weight='medium'>
|
||||
<FormattedMessage id='app_create.results.explanation_title' defaultMessage='App created successfully' />
|
||||
</Text>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='app_create.results.explanation_text'
|
||||
defaultMessage='You created a new app and token! Please copy the credentials somewhere; you will not see them again after navigating away from this page.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.results.app_label' defaultMessage='App' />}>
|
||||
<Textarea
|
||||
value={JSON.stringify(app, null, 2)}
|
||||
rows={10}
|
||||
readOnly
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.results.token_label' defaultMessage='OAuth token' />}>
|
||||
<Textarea
|
||||
value={JSON.stringify(token, null, 2)}
|
||||
rows={10}
|
||||
readOnly
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='button' onClick={handleReset}>
|
||||
<FormattedMessage id='app_create.restart' defaultMessage='Create another' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
if (app && token) {
|
||||
return renderResults();
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.name_label' defaultMessage='App name' />}>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.namePlaceholder)}
|
||||
onChange={handleParamChange('client_name')}
|
||||
value={params.client_name}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.website_label' defaultMessage='Website' />}>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='https://soapbox.pub'
|
||||
onChange={handleParamChange('website')}
|
||||
value={params.website}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.redirect_uri_label' defaultMessage='Redirect URIs' />}>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='https://example.com'
|
||||
onChange={handleParamChange('redirect_uris')}
|
||||
value={params.redirect_uris}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup labelText={<FormattedMessage id='app_create.scopes_label' defaultMessage='Scopes' />}>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.scopesPlaceholder)}
|
||||
onChange={handleParamChange('scopes')}
|
||||
value={params.scopes}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='submit' disabled={isLoading}>
|
||||
<FormattedMessage id='app_create.submit' defaultMessage='Create app' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateApp;
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import DevelopersChallenge from './developers_challenge';
|
||||
import DevelopersMenu from './developers_menu';
|
||||
import DevelopersChallenge from './developers-challenge';
|
||||
import DevelopersMenu from './developers-menu';
|
||||
|
||||
const Developers: React.FC = () => {
|
||||
const isDeveloper = useAppSelector((state) => getSettings(state).get('isDeveloper'));
|
||||
|
|
153
app/soapbox/features/developers/settings-store.tsx
Normal file
153
app/soapbox/features/developers/settings-store.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import { showAlertForError } from 'soapbox/actions/alerts';
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import { FE_NAME, SETTINGS_UPDATE, changeSetting } from 'soapbox/actions/settings';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Column,
|
||||
Button,
|
||||
Form,
|
||||
FormActions,
|
||||
FormGroup,
|
||||
Textarea,
|
||||
} from 'soapbox/components/ui';
|
||||
import SettingToggle from 'soapbox/features/notifications/components/setting_toggle';
|
||||
import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks';
|
||||
|
||||
const isJSONValid = (text: any): boolean => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
|
||||
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
|
||||
});
|
||||
|
||||
const SettingsStore: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useSettings();
|
||||
const settingsStore = useAppSelector(state => state.get('settings'));
|
||||
|
||||
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(settingsStore, null, 2));
|
||||
const [jsonValid, setJsonValid] = useState(true);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const handleEditJSON: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||
const rawJSON = target.value;
|
||||
setRawJSON(rawJSON);
|
||||
setJsonValid(isJSONValid(rawJSON));
|
||||
};
|
||||
|
||||
const onToggleChange = (key: string[], checked: boolean) => {
|
||||
dispatch(changeSetting(key, checked, { showAlert: true }));
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler = e => {
|
||||
const settings = JSON.parse(rawJSON);
|
||||
|
||||
setLoading(true);
|
||||
dispatch(patchMe({
|
||||
pleroma_settings_store: {
|
||||
[FE_NAME]: settings,
|
||||
},
|
||||
})).then(response => {
|
||||
dispatch({ type: SETTINGS_UPDATE, settings });
|
||||
setLoading(false);
|
||||
}).catch(error => {
|
||||
dispatch(showAlertForError(error));
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRawJSON(JSON.stringify(settingsStore, null, 2));
|
||||
setJsonValid(true);
|
||||
}, [settingsStore]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup
|
||||
hintText={intl.formatMessage(messages.hint)}
|
||||
errors={jsonValid ? [] : ['is invalid']}
|
||||
>
|
||||
<Textarea
|
||||
value={rawJSON}
|
||||
onChange={handleEditJSON}
|
||||
disabled={isLoading}
|
||||
rows={12}
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='submit' disabled={!jsonValid || isLoading}>
|
||||
<FormattedMessage id='soapbox_config.save' defaultMessage='Save' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle title='Advanced settings' />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
|
||||
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.unfollow_modal_label' defaultMessage='Show confirmation dialog before unfollowing someone' />}>
|
||||
<SettingToggle settings={settings} settingPath={['unfollowModal']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.missing_description_modal_label' defaultMessage='Show confirmation dialog before sending a post without media descriptions' />}>
|
||||
<SettingToggle settings={settings} settingPath={['missingDescriptionModal']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.reduce_motion_label' defaultMessage='Reduce motion in animations' />}>
|
||||
<SettingToggle settings={settings} settingPath={['reduceMotion']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.underline_links_label' defaultMessage='Always underline links in posts' />}>
|
||||
<SettingToggle settings={settings} settingPath={['underlineLinks']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.system_font_label' defaultMessage="Use system's default font" />}>
|
||||
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<div className='dyslexic'>
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.dyslexic_font_label' defaultMessage='Dyslexic mode' />}>
|
||||
<SettingToggle settings={settings} settingPath={['dyslexicFont']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
</div>
|
||||
|
||||
{/* <ListItem
|
||||
label={<FormattedMessage id='preferences.fields.halloween_label' defaultMessage='Halloween mode' />}
|
||||
hint={<FormattedMessage id='preferences.hints.halloween' defaultMessage='Beware: SPOOKY! Supports light/dark toggle.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['halloween']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
|
||||
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['demetricator']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsStore;
|
|
@ -1,116 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { showAlertForError } from 'soapbox/actions/alerts';
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import { FE_NAME, SETTINGS_UPDATE } from 'soapbox/actions/settings';
|
||||
import { Button, Form, FormActions, FormGroup, Textarea } from 'soapbox/components/ui';
|
||||
import Column from 'soapbox/features/ui/components/column';
|
||||
|
||||
const isJSONValid = text => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
|
||||
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
settingsStore: state.get('settings'),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class SettingsStore extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
settingsStore: ImmutablePropTypes.map.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
rawJSON: JSON.stringify(this.props.settingsStore, null, 2),
|
||||
jsonValid: true,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { settingsStore } = this.props;
|
||||
|
||||
if (settingsStore !== prevProps.settingsStore) {
|
||||
this.setState({
|
||||
rawJSON: JSON.stringify(settingsStore, null, 2),
|
||||
jsonValid: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEditJSON = ({ target }) => {
|
||||
const rawJSON = target.value;
|
||||
this.setState({ rawJSON, jsonValid: isJSONValid(rawJSON) });
|
||||
}
|
||||
|
||||
handleSubmit = e => {
|
||||
const { dispatch } = this.props;
|
||||
const { rawJSON } = this.state;
|
||||
|
||||
const settings = JSON.parse(rawJSON);
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
dispatch(patchMe({
|
||||
pleroma_settings_store: {
|
||||
[FE_NAME]: settings,
|
||||
},
|
||||
})).then(response => {
|
||||
dispatch({ type: SETTINGS_UPDATE, settings });
|
||||
this.setState({ isLoading: false });
|
||||
}).catch(error => {
|
||||
dispatch(showAlertForError(error));
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
const { rawJSON, jsonValid, isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<FormGroup
|
||||
hintText={intl.formatMessage(messages.hint)}
|
||||
errors={jsonValid ? [] : ['is invalid']}
|
||||
>
|
||||
<Textarea
|
||||
value={rawJSON}
|
||||
onChange={this.handleEditJSON}
|
||||
disabled={isLoading}
|
||||
rows={12}
|
||||
isCodeEditor
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='submit' disabled={!jsonValid || isLoading}>
|
||||
<FormattedMessage id='soapbox_config.save' defaultMessage='Save' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -15,13 +15,24 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
|
|||
const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId);
|
||||
const isSelected = avatar.account_id === selectedAccountId;
|
||||
|
||||
const handleClick = () =>
|
||||
isSelected
|
||||
? dispatch(replaceHomeTimeline(null, { maxId: null }))
|
||||
: dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }));
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (isSelected) {
|
||||
dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
|
||||
} else {
|
||||
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
|
||||
<div aria-disabled={isLoading} onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
|
||||
<Stack className='w-16 h-auto' space={3}>
|
||||
<div className='block mx-auto relative w-14 h-14 rounded-full'>
|
||||
{isSelected && (
|
||||
|
|
|
@ -11,8 +11,8 @@ import ActionButton from '../ui/components/action-button';
|
|||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'feedSuggestions.heading', defaultMessage: 'Suggested profiles' },
|
||||
viewAll: { id: 'feedSuggestions.viewAll', defaultMessage: 'View all' },
|
||||
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
|
||||
viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
|
||||
});
|
||||
|
||||
const SuggestionItem = ({ accountId }: { accountId: string }) => {
|
||||
|
|
|
@ -216,7 +216,7 @@ const Filters = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<Icon className='filter__delete-icon' id='times' size={40} />
|
||||
<Icon className='filter__delete-icon' id='times' />
|
||||
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
|
@ -59,47 +60,48 @@ const HomeTimeline: React.FC = () => {
|
|||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<Timeline
|
||||
scrollKey='home_timeline'
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
timelineId='home'
|
||||
divideType='space'
|
||||
emptyMessage={
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='medium' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.title'
|
||||
defaultMessage="You're not following anyone yet"
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.subtitle'
|
||||
defaultMessage='{siteTitle} gets more interesting once you follow other users.'
|
||||
values={{ siteTitle }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{features.federating && (
|
||||
<Text theme='muted' align='center'>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Timeline
|
||||
scrollKey='home_timeline'
|
||||
onLoadMore={handleLoadMore}
|
||||
timelineId='home'
|
||||
divideType='space'
|
||||
emptyMessage={
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='medium' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home'
|
||||
defaultMessage='Or you can visit {public} to get started and meet other users.'
|
||||
values={{
|
||||
public: (
|
||||
<Link to='/timeline/local' className='text-primary-600 dark:text-primary-400 hover:underline'>
|
||||
<FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
id='empty_column.home.title'
|
||||
defaultMessage="You're not following anyone yet"
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.subtitle'
|
||||
defaultMessage='{siteTitle} gets more interesting once you follow other users.'
|
||||
values={{ siteTitle }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{features.federating && (
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home'
|
||||
defaultMessage='Or you can visit {public} to get started and meet other users.'
|
||||
values={{
|
||||
public: (
|
||||
<Link to='/timeline/local' className='text-primary-600 dark:text-primary-400 hover:underline'>
|
||||
<FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { updateNotifications } from '../../../../actions/notifications';
|
||||
import { render, screen, rootState, createTestStore } from '../../../../jest/test-helpers';
|
||||
import { makeGetNotification } from '../../../../selectors';
|
||||
import Notification from '../notification';
|
||||
import { updateNotifications } from 'soapbox/actions/notifications';
|
||||
import { render, screen, rootState, createTestStore } from 'soapbox/jest/test-helpers';
|
||||
|
||||
const getNotification = makeGetNotification();
|
||||
import Notification from '../notification';
|
||||
|
||||
/** Prepare the notification for use by the component */
|
||||
const normalize = (notification: any) => {
|
||||
|
@ -15,7 +13,7 @@ const normalize = (notification: any) => {
|
|||
|
||||
return {
|
||||
// @ts-ignore
|
||||
notification: getNotification(state, state.notifications.items.get(notification.id)),
|
||||
notification: state.notifications.items.get(notification.id),
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
export default class ClearColumnButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon src={require('@tabler/icons/eraser.svg')} /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
interface IClearColumnButton {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const ClearColumnButton: React.FC<IClearColumnButton> = ({ onClick }) => (
|
||||
<button className='text-btn column-header__setting-btn' tabIndex={0} onClick={onClick}>
|
||||
<Icon src={require('@tabler/icons/eraser.svg')} />
|
||||
{' '}
|
||||
<FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' />
|
||||
</button>
|
||||
);
|
||||
|
||||
export default ClearColumnButton;
|
|
@ -1,106 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Tabs } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
|
||||
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
|
||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class NotificationFilterBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
selectFilter: PropTypes.func.isRequired,
|
||||
selectedFilter: PropTypes.string.isRequired,
|
||||
advancedMode: PropTypes.bool.isRequired,
|
||||
supportsEmojiReacts: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onClick(notificationType) {
|
||||
return () => this.props.selectFilter(notificationType);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props;
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: this.onClick('all'),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
if (!advancedMode) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.mentions),
|
||||
action: this.onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/at.svg')} />,
|
||||
title: intl.formatMessage(messages.mentions),
|
||||
action: this.onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/heart.svg')} />,
|
||||
title: intl.formatMessage(messages.favourites),
|
||||
action: this.onClick('favourite'),
|
||||
name: 'favourite',
|
||||
});
|
||||
if (supportsEmojiReacts) items.push({
|
||||
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
|
||||
title: intl.formatMessage(messages.emoji_reacts),
|
||||
action: this.onClick('pleroma:emoji_reaction'),
|
||||
name: 'pleroma:emoji_reaction',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
|
||||
title: intl.formatMessage(messages.boosts),
|
||||
action: this.onClick('reblog'),
|
||||
name: 'reblog',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
|
||||
title: intl.formatMessage(messages.polls),
|
||||
action: this.onClick('poll'),
|
||||
name: 'poll',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
|
||||
title: intl.formatMessage(messages.statuses),
|
||||
action: this.onClick('status'),
|
||||
name: 'status',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
|
||||
title: intl.formatMessage(messages.follows),
|
||||
action: this.onClick('follow'),
|
||||
name: 'follow',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
|
||||
title: intl.formatMessage(messages.moves),
|
||||
action: this.onClick('move'),
|
||||
name: 'move',
|
||||
});
|
||||
}
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
}
|
||||
|
||||
}
|
102
app/soapbox/features/notifications/components/filter_bar.tsx
Normal file
102
app/soapbox/features/notifications/components/filter_bar.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { setFilter } from 'soapbox/actions/notifications';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Tabs } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useFeatures, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||
|
||||
const messages = defineMessages({
|
||||
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
|
||||
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
|
||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||
});
|
||||
|
||||
const NotificationFilterBar = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useSettings();
|
||||
const features = useFeatures();
|
||||
|
||||
const selectedFilter = settings.getIn(['notifications', 'quickFilter', 'active']) as string;
|
||||
const advancedMode = settings.getIn(['notifications', 'quickFilter', 'advanced']);
|
||||
|
||||
const onClick = (notificationType: string) => () => dispatch(setFilter(notificationType));
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: onClick('all'),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
if (!advancedMode) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.mentions),
|
||||
action: onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/at.svg')} />,
|
||||
title: intl.formatMessage(messages.mentions),
|
||||
action: onClick('mention'),
|
||||
name: 'mention',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/heart.svg')} />,
|
||||
title: intl.formatMessage(messages.favourites),
|
||||
action: onClick('favourite'),
|
||||
name: 'favourite',
|
||||
});
|
||||
if (features.emojiReacts) items.push({
|
||||
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
|
||||
title: intl.formatMessage(messages.emoji_reacts),
|
||||
action: onClick('pleroma:emoji_reaction'),
|
||||
name: 'pleroma:emoji_reaction',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
|
||||
title: intl.formatMessage(messages.boosts),
|
||||
action: onClick('reblog'),
|
||||
name: 'reblog',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
|
||||
title: intl.formatMessage(messages.polls),
|
||||
action: onClick('poll'),
|
||||
name: 'poll',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
|
||||
title: intl.formatMessage(messages.statuses),
|
||||
action: onClick('status'),
|
||||
name: 'status',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
|
||||
title: intl.formatMessage(messages.follows),
|
||||
action: onClick('follow'),
|
||||
name: 'follow',
|
||||
});
|
||||
items.push({
|
||||
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
|
||||
title: intl.formatMessage(messages.moves),
|
||||
action: onClick('move'),
|
||||
name: 'move',
|
||||
});
|
||||
}
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
export default NotificationFilterBar;
|
|
@ -1,19 +1,27 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { mentionCompose } from 'soapbox/actions/compose';
|
||||
import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import Permalink from 'soapbox/components/permalink';
|
||||
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import StatusContainer from 'soapbox/containers/status_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetNotification } from 'soapbox/selectors';
|
||||
import { NotificationType, validType } from 'soapbox/utils/notification';
|
||||
|
||||
import type { ScrollPosition } from 'soapbox/components/status';
|
||||
import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getNotification = makeGetNotification();
|
||||
|
||||
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
|
||||
const output = [message];
|
||||
|
||||
|
@ -130,17 +138,17 @@ interface INotificaton {
|
|||
notification: NotificationEntity,
|
||||
onMoveUp?: (notificationId: string) => void,
|
||||
onMoveDown?: (notificationId: string) => void,
|
||||
onMention?: (account: Account) => void,
|
||||
onFavourite?: (status: Status) => void,
|
||||
onReblog?: (status: Status, e?: KeyboardEvent) => void,
|
||||
onToggleHidden?: (status: Status) => void,
|
||||
getScrollPosition?: () => ScrollPosition | undefined,
|
||||
updateScrollBottom?: (bottom: number) => void,
|
||||
siteTitle?: string,
|
||||
}
|
||||
|
||||
const Notification: React.FC<INotificaton> = (props) => {
|
||||
const { hidden = false, notification, onMoveUp, onMoveDown } = props;
|
||||
const { hidden = false, onMoveUp, onMoveDown } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const notification = useAppSelector((state) => getNotification(state, props.notification));
|
||||
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
|
@ -175,31 +183,52 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleMention = (e?: KeyboardEvent) => {
|
||||
const handleMention = useCallback((e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (props.onMention && account && typeof account === 'object') {
|
||||
props.onMention(account);
|
||||
if (account && typeof account === 'object') {
|
||||
dispatch(mentionCompose(account));
|
||||
}
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
const handleHotkeyFavourite = (e?: KeyboardEvent) => {
|
||||
if (props.onFavourite && status && typeof status === 'object') {
|
||||
props.onFavourite(status);
|
||||
const handleHotkeyFavourite = useCallback((e?: KeyboardEvent) => {
|
||||
if (status && typeof status === 'object') {
|
||||
if (status.favourited) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const handleHotkeyBoost = (e?: KeyboardEvent) => {
|
||||
if (props.onReblog && status && typeof status === 'object') {
|
||||
props.onReblog(status, e);
|
||||
const handleHotkeyBoost = useCallback((e?: KeyboardEvent) => {
|
||||
if (status && typeof status === 'object') {
|
||||
dispatch((_, getState) => {
|
||||
const boostModal = getSettings(getState()).get('boostModal');
|
||||
if (status.reblogged) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if (e?.shiftKey || !boostModal) {
|
||||
dispatch(reblog(status));
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: (status: Status) => {
|
||||
dispatch(reblog(status));
|
||||
} }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const handleHotkeyToggleHidden = (e?: KeyboardEvent) => {
|
||||
if (props.onToggleHidden && status && typeof status === 'object') {
|
||||
props.onToggleHidden(status);
|
||||
const handleHotkeyToggleHidden = useCallback((e?: KeyboardEvent) => {
|
||||
if (status && typeof status === 'object') {
|
||||
if (status.hidden) {
|
||||
dispatch(revealStatus(status.id));
|
||||
} else {
|
||||
dispatch(hideStatus(status.id));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const handleMoveUp = () => {
|
||||
if (onMoveUp) {
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { setFilter } from 'soapbox/actions/notifications';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import FilterBar from '../components/filter_bar';
|
||||
|
||||
const makeMapStateToProps = state => {
|
||||
const settings = getSettings(state);
|
||||
const instance = state.get('instance');
|
||||
const features = getFeatures(instance);
|
||||
|
||||
return {
|
||||
selectedFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
|
||||
advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']),
|
||||
supportsEmojiReacts: features.emojiReacts,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
selectFilter(newActiveFilter) {
|
||||
dispatch(setFilter(newActiveFilter));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
|
|
@ -1,74 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { mentionCompose } from 'soapbox/actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
} from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import {
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
} from 'soapbox/actions/statuses';
|
||||
import { makeGetNotification } from 'soapbox/selectors';
|
||||
|
||||
import Notification from '../components/notification';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getNotification = makeGetNotification();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
return {
|
||||
siteTitle: state.getIn(['instance', 'title']),
|
||||
notification: getNotification(state, props.notification),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onMention: (account) => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
|
||||
onModalReblog(status) {
|
||||
dispatch(reblog(status));
|
||||
},
|
||||
|
||||
onReblog(status, e) {
|
||||
dispatch((_, getState) => {
|
||||
const boostModal = getSettings(getState()).get('boostModal');
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onFavourite(status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
onToggleHidden(status) {
|
||||
if (status.get('hidden')) {
|
||||
dispatch(revealStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(hideStatus(status.get('id')));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
|
|
@ -1,213 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import debounce from 'lodash/debounce';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
expandNotifications,
|
||||
scrollTopNotifications,
|
||||
dequeueNotifications,
|
||||
} from 'soapbox/actions/notifications';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import ScrollTopButton from 'soapbox/components/scroll-top-button';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
|
||||
|
||||
import FilterBarContainer from './containers/filter_bar_container';
|
||||
import NotificationContainer from './containers/notification_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
|
||||
});
|
||||
|
||||
const getNotifications = createSelector([
|
||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
|
||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
|
||||
state => ImmutableList(getSettings(state).getIn(['notifications', 'shows']).filter(item => !item).keys()),
|
||||
state => state.getIn(['notifications', 'items']).toList(),
|
||||
], (showFilterBar, allowedType, excludedTypes, notifications) => {
|
||||
if (!showFilterBar || allowedType === 'all') {
|
||||
// used if user changed the notification settings after loading the notifications from the server
|
||||
// otherwise a list of notifications will come pre-filtered from the backend
|
||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
||||
}
|
||||
return notifications.filter(item => item !== null && allowedType === item.get('type'));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const settings = getSettings(state);
|
||||
|
||||
return {
|
||||
showFilterBar: settings.getIn(['notifications', 'quickFilter', 'show']),
|
||||
activeFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
|
||||
notifications: getNotifications(state),
|
||||
isLoading: state.getIn(['notifications', 'isLoading'], true),
|
||||
isUnread: state.getIn(['notifications', 'unread']) > 0,
|
||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||
totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Notifications extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
notifications: ImmutablePropTypes.list.isRequired,
|
||||
showFilterBar: PropTypes.bool.isRequired,
|
||||
activeFilter: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
isUnread: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
dequeueNotifications: PropTypes.func,
|
||||
totalQueuedNotificationsCount: PropTypes.number,
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
this.handleLoadOlder.cancel();
|
||||
this.handleScrollToTop.cancel();
|
||||
this.handleScroll.cancel();
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.handleDequeueNotifications();
|
||||
this.props.dispatch(scrollTopNotifications(true));
|
||||
}
|
||||
|
||||
handleLoadGap = (maxId) => {
|
||||
this.props.dispatch(expandNotifications({ maxId }));
|
||||
};
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const last = this.props.notifications.last();
|
||||
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
||||
}, 300, { leading: true });
|
||||
|
||||
handleScrollToTop = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(true));
|
||||
}, 100);
|
||||
|
||||
handleScroll = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
}, 100);
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
_selectChild(index) {
|
||||
this.node.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const container = this.column;
|
||||
const element = container.querySelector(`[data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleDequeueNotifications = () => {
|
||||
this.props.dispatch(dequeueNotifications());
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
const { dispatch } = this.props;
|
||||
return dispatch(expandNotifications());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, notifications, isLoading, hasMore, showFilterBar, totalQueuedNotificationsCount, activeFilter } = this.props;
|
||||
|
||||
const emptyMessage = activeFilter === 'all'
|
||||
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
|
||||
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
|
||||
|
||||
let scrollableContent = null;
|
||||
|
||||
const filterBarContainer = showFilterBar
|
||||
? (<FilterBarContainer />)
|
||||
: null;
|
||||
|
||||
if (isLoading && this.scrollableContent) {
|
||||
scrollableContent = this.scrollableContent;
|
||||
} else if (notifications.size > 0 || hasMore) {
|
||||
scrollableContent = notifications.map((item, index) => (
|
||||
<NotificationContainer
|
||||
key={item.get('id')}
|
||||
notification={item}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
scrollableContent = null;
|
||||
}
|
||||
|
||||
this.scrollableContent = scrollableContent;
|
||||
|
||||
const scrollContainer = (
|
||||
<ScrollableList
|
||||
ref={this.setRef}
|
||||
scrollKey='notifications'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
placeholderComponent={PlaceholderNotification}
|
||||
placeholderCount={20}
|
||||
onLoadMore={this.handleLoadOlder}
|
||||
onRefresh={this.handleRefresh}
|
||||
onScrollToTop={this.handleScrollToTop}
|
||||
onScroll={this.handleScroll}
|
||||
className={classNames({
|
||||
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
|
||||
'space-y-2': notifications.size === 0,
|
||||
})}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)} withHeader={false}>
|
||||
{filterBarContainer}
|
||||
<ScrollTopButton
|
||||
onClick={this.handleDequeueNotifications}
|
||||
count={totalQueuedNotificationsCount}
|
||||
message={messages.queue}
|
||||
/>
|
||||
{scrollContainer}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
191
app/soapbox/features/notifications/index.tsx
Normal file
191
app/soapbox/features/notifications/index.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
import classNames from 'classnames';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
expandNotifications,
|
||||
scrollTopNotifications,
|
||||
dequeueNotifications,
|
||||
} from 'soapbox/actions/notifications';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import ScrollTopButton from 'soapbox/components/scroll-top-button';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import FilterBar from './components/filter_bar';
|
||||
import Notification from './components/notification';
|
||||
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Notification as NotificationEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
|
||||
});
|
||||
|
||||
const getNotifications = createSelector([
|
||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
|
||||
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
|
||||
state => ImmutableList((getSettings(state).getIn(['notifications', 'shows']) as ImmutableMap<string, boolean>).filter(item => !item).keys()),
|
||||
(state: RootState) => state.notifications.items.toList(),
|
||||
], (showFilterBar, allowedType, excludedTypes, notifications: ImmutableList<NotificationEntity>) => {
|
||||
if (!showFilterBar || allowedType === 'all') {
|
||||
// used if user changed the notification settings after loading the notifications from the server
|
||||
// otherwise a list of notifications will come pre-filtered from the backend
|
||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
||||
}
|
||||
return notifications.filter(item => item !== null && allowedType === item.get('type'));
|
||||
});
|
||||
|
||||
const Notifications = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
|
||||
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
|
||||
const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']);
|
||||
const notifications = useAppSelector(state => getNotifications(state));
|
||||
const isLoading = useAppSelector(state => state.notifications.isLoading);
|
||||
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
|
||||
const hasMore = useAppSelector(state => state.notifications.hasMore);
|
||||
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
|
||||
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
const column = useRef<HTMLDivElement>(null);
|
||||
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
|
||||
|
||||
// const handleLoadGap = (maxId) => {
|
||||
// dispatch(expandNotifications({ maxId }));
|
||||
// };
|
||||
|
||||
const handleLoadOlder = useCallback(debounce(() => {
|
||||
const last = notifications.last();
|
||||
dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleScrollToTop = useCallback(debounce(() => {
|
||||
dispatch(scrollTopNotifications(true));
|
||||
}, 100), []);
|
||||
|
||||
const handleScroll = useCallback(debounce(() => {
|
||||
dispatch(scrollTopNotifications(false));
|
||||
}, 100), []);
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
_selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
_selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const _selectChild = (index: number) => {
|
||||
node.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const container = column.current;
|
||||
const element = container?.querySelector(`[data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
(element as HTMLDivElement).focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDequeueNotifications = () => {
|
||||
dispatch(dequeueNotifications());
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(expandNotifications());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleDequeueNotifications();
|
||||
dispatch(scrollTopNotifications(true));
|
||||
|
||||
return () => {
|
||||
handleLoadOlder.cancel();
|
||||
handleScrollToTop.cancel();
|
||||
handleScroll.cancel();
|
||||
dispatch(scrollTopNotifications(false));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emptyMessage = activeFilter === 'all'
|
||||
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
|
||||
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
|
||||
|
||||
let scrollableContent: ImmutableList<JSX.Element> | null = null;
|
||||
|
||||
const filterBarContainer = showFilterBar
|
||||
? (<FilterBar />)
|
||||
: null;
|
||||
|
||||
if (isLoading && scrollableContentRef.current) {
|
||||
scrollableContent = scrollableContentRef.current;
|
||||
} else if (notifications.size > 0 || hasMore) {
|
||||
scrollableContent = notifications.map((item) => (
|
||||
<Notification
|
||||
key={item.id}
|
||||
notification={item}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
scrollableContent = null;
|
||||
}
|
||||
|
||||
scrollableContentRef.current = scrollableContent;
|
||||
|
||||
const scrollContainer = (
|
||||
<ScrollableList
|
||||
ref={node}
|
||||
scrollKey='notifications'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
placeholderComponent={PlaceholderNotification}
|
||||
placeholderCount={20}
|
||||
onLoadMore={handleLoadOlder}
|
||||
onScrollToTop={handleScrollToTop}
|
||||
onScroll={handleScroll}
|
||||
className={classNames({
|
||||
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
|
||||
'space-y-2': notifications.size === 0,
|
||||
})}
|
||||
>
|
||||
{scrollableContent as ImmutableList<JSX.Element>}
|
||||
</ScrollableList>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column ref={column} label={intl.formatMessage(messages.title)} withHeader={false}>
|
||||
{filterBarContainer}
|
||||
<ScrollTopButton
|
||||
onClick={handleDequeueNotifications}
|
||||
count={totalQueuedNotificationsCount}
|
||||
message={messages.queue}
|
||||
/>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{scrollContainer}
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
|
@ -1,66 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
|
||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||
import StatusList from 'soapbox/components/status_list';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { params }) => {
|
||||
const username = params.username || '';
|
||||
const me = state.get('me');
|
||||
const meUsername = state.getIn(['accounts', me, 'username'], '');
|
||||
return {
|
||||
isMyAccount: (username.toLowerCase() === meUsername.toLowerCase()),
|
||||
statusIds: state.status_lists.get('pins').items,
|
||||
hasMore: !!state.status_lists.get('pins').next,
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class PinnedStatuses extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.orderedSet.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
isMyAccount: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchPinnedStatuses());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, statusIds, hasMore, isMyAccount } = this.props;
|
||||
|
||||
if (!isMyAccount) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='pinned_statuses'
|
||||
hasMore={hasMore}
|
||||
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
51
app/soapbox/features/pinned_statuses/index.tsx
Normal file
51
app/soapbox/features/pinned_statuses/index.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
|
||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||
import StatusList from 'soapbox/components/status_list';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
|
||||
});
|
||||
|
||||
const PinnedStatuses = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { username } = useParams<{ username: string }>();
|
||||
|
||||
const meUsername = useAppSelector((state) => state.accounts.get(state.me)?.username || '');
|
||||
const statusIds = useAppSelector((state) => state.status_lists.get('pins')!.items);
|
||||
const isLoading = useAppSelector((state) => !!state.status_lists.get('pins')!.isLoading);
|
||||
const hasMore = useAppSelector((state) => !!state.status_lists.get('pins')!.next);
|
||||
|
||||
const isMyAccount = username.toLowerCase() === meUsername.toLowerCase();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPinnedStatuses());
|
||||
}, []);
|
||||
|
||||
if (!isMyAccount) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent withHeader={false}>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='pinned_statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default PinnedStatuses;
|
|
@ -2,13 +2,12 @@ import React from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { getSettings, changeSetting } from 'soapbox/actions/settings';
|
||||
import { changeSetting } from 'soapbox/actions/settings';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Form } from 'soapbox/components/ui';
|
||||
import { SelectDropdown } from 'soapbox/features/forms';
|
||||
import SettingToggle from 'soapbox/features/notifications/components/setting_toggle';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { useFeatures, useSettings } from 'soapbox/hooks';
|
||||
|
||||
import ThemeToggle from '../ui/components/theme-toggle';
|
||||
|
||||
|
@ -91,9 +90,8 @@ const messages = defineMessages({
|
|||
const Preferences = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const features = useAppSelector((state) => getFeatures(state.instance));
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
|
||||
const onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>, path: string[]) => {
|
||||
dispatch(changeSetting(path, event.target.value, { showAlert: true }));
|
||||
|
@ -159,75 +157,28 @@ const Preferences = () => {
|
|||
/>
|
||||
</ListItem>
|
||||
|
||||
{features.privacyScopes && <ListItem label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Default post privacy' />}>
|
||||
<SelectDropdown
|
||||
items={defaultPrivacyOptions}
|
||||
defaultValue={settings.get('defaultPrivacy') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultPrivacy'])}
|
||||
/>
|
||||
</ListItem>}
|
||||
{features.privacyScopes && (
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Default post privacy' />}>
|
||||
<SelectDropdown
|
||||
items={defaultPrivacyOptions}
|
||||
defaultValue={settings.get('defaultPrivacy') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultPrivacy'])}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{features.richText && <ListItem label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Default post format' />}>
|
||||
<SelectDropdown
|
||||
items={defaultContentTypeOptions}
|
||||
defaultValue={settings.get('defaultContentType') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultContentType'])}
|
||||
/>
|
||||
</ListItem>}
|
||||
{features.richText && (
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Default post format' />}>
|
||||
<SelectDropdown
|
||||
items={defaultContentTypeOptions}
|
||||
defaultValue={settings.get('defaultContentType') as string | undefined}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultContentType'])}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* <FieldsGroup>
|
||||
<RadioGroup
|
||||
label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Post privacy' />}
|
||||
onChange={this.onDefaultPrivacyChange}
|
||||
>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='preferences.options.privacy_public' defaultMessage='Public' />}
|
||||
hint={<FormattedMessage id='preferences.hints.privacy_public' defaultMessage='Everyone can see' />}
|
||||
checked={settings.get('defaultPrivacy') === 'public'}
|
||||
value='public'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='preferences.options.privacy_unlisted' defaultMessage='Unlisted' />}
|
||||
hint={<FormattedMessage id='preferences.hints.privacy_unlisted' defaultMessage='Everyone can see, but not listed on public timelines' />}
|
||||
checked={settings.get('defaultPrivacy') === 'unlisted'}
|
||||
value='unlisted'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='preferences.options.privacy_followers_only' defaultMessage='Followers-only' />}
|
||||
hint={<FormattedMessage id='preferences.hints.privacy_followers_only' defaultMessage='Only show to followers' />}
|
||||
checked={settings.get('defaultPrivacy') === 'private'}
|
||||
value='private'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldsGroup> */}
|
||||
|
||||
{/* {features.richText && (
|
||||
<FieldsGroup>
|
||||
<RadioGroup
|
||||
label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Post format' />}
|
||||
onChange={onDefaultContentTypeChange}
|
||||
>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='preferences.options.content_type_plaintext' defaultMessage='Plain text' />}
|
||||
checked={settings.get('defaultContentType') === 'text/plain'}
|
||||
value='text/plain'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='preferences.options.content_type_markdown' defaultMessage='Markdown' />}
|
||||
hint={<FormattedMessage id='preferences.hints.content_type_markdown' defaultMessage='Warning: experimental!' />}
|
||||
checked={settings.get('defaultContentType') === 'text/markdown'}
|
||||
value='text/markdown'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldsGroup>
|
||||
)} */}
|
||||
|
||||
<List>
|
||||
{/* <ListItem label={<FormattedMessage id='preferences.fields.unfollow_modal_label' defaultMessage='Show confirmation dialog before unfollowing someone' />}>
|
||||
<SettingToggle settings={settings} settingPath={['unfollowModal']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.boost_modal_label' defaultMessage='Show confirmation dialog before reposting' />}>
|
||||
<SettingToggle settings={settings} settingPath={['boostModal']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
@ -235,10 +186,6 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.delete_modal_label' defaultMessage='Show confirmation dialog before deleting a post' />}>
|
||||
<SettingToggle settings={settings} settingPath={['deleteModal']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
{/* <ListItem label={<FormattedMessage id='preferences.fields.missing_description_modal_label' defaultMessage='Show confirmation dialog before sending a post without media descriptions' />}>
|
||||
<SettingToggle settings={settings} settingPath={['missingDescriptionModal']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
</List>
|
||||
|
||||
<List>
|
||||
|
@ -250,10 +197,6 @@ const Preferences = () => {
|
|||
<SettingToggle settings={settings} settingPath={['expandSpoilers']} onChange={onToggleChange} />
|
||||
</ListItem>}
|
||||
|
||||
{/* <ListItem label={<FormattedMessage id='preferences.fields.reduce_motion_label' defaultMessage='Reduce motion in animations' />}>
|
||||
<SettingToggle settings={settings} settingPath={['reduceMotion']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.fields.autoload_timelines_label' defaultMessage='Automatically load new posts when scrolled to the top of the page' />}>
|
||||
<SettingToggle settings={settings} settingPath={['autoloadTimelines']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
@ -261,32 +204,6 @@ const Preferences = () => {
|
|||
<ListItem label={<FormattedMessage id='preferences.fields.autoload_more_label' defaultMessage='Automatically load more items when scrolled to the bottom of the page' />}>
|
||||
<SettingToggle settings={settings} settingPath={['autoloadMore']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
{/* <ListItem label={<FormattedMessage id='preferences.fields.underline_links_label' defaultMessage='Always underline links in posts' />}>
|
||||
<SettingToggle settings={settings} settingPath={['underlineLinks']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
|
||||
{/* <ListItem label={<FormattedMessage id='preferences.fields.system_font_label' defaultMessage="Use system's default font" />}>
|
||||
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
|
||||
{/* <div className='dyslexic'>
|
||||
<SettingsCheckbox
|
||||
label={<FormattedMessage id='preferences.fields.dyslexic_font_label' defaultMessage='Dyslexic mode' />}
|
||||
path={['dyslexicFont']}
|
||||
/>
|
||||
</div> */}
|
||||
{/* <SettingsCheckbox
|
||||
label={<FormattedMessage id='preferences.fields.halloween_label' defaultMessage='Halloween mode' />}
|
||||
hint={<FormattedMessage id='preferences.hints.halloween' defaultMessage='Beware: SPOOKY! Supports light/dark toggle.' />}
|
||||
path={['halloween']}
|
||||
/> */}
|
||||
{/* <ListItem
|
||||
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
|
||||
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['demetricator']} onChange={onToggleChange} />
|
||||
</ListItem> */}
|
||||
</List>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
|
||||
return {
|
||||
copyright: soapboxConfig.get('copyright'),
|
||||
navlinks: soapboxConfig.getIn(['navlinks', 'homeFooter'], ImmutableList()),
|
||||
locale: getSettings(state).get('locale'),
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
class Footer extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
copyright: PropTypes.string,
|
||||
locale: PropTypes.string,
|
||||
navlinks: ImmutablePropTypes.list,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { copyright, locale, navlinks } = this.props;
|
||||
|
||||
return (
|
||||
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
|
||||
<div className='flex flex-wrap justify-center'>
|
||||
{navlinks.map((link, idx) => {
|
||||
const url = link.get('url');
|
||||
const isExternal = url.startsWith('http');
|
||||
const Comp = isExternal ? 'a' : Link;
|
||||
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
|
||||
|
||||
return (
|
||||
<div key={idx} className='px-5 py-2'>
|
||||
<Comp {...compProps} className='hover:underline'>
|
||||
<Text tag='span' theme='primary' size='sm'>
|
||||
{link.getIn(['titleLocales', locale]) || link.get('title')}
|
||||
</Text>
|
||||
</Comp>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 xl:mt-0'>
|
||||
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
51
app/soapbox/features/public_layout/components/footer.tsx
Normal file
51
app/soapbox/features/public_layout/components/footer.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { FooterItem } from 'soapbox/types/soapbox';
|
||||
|
||||
const Footer = () => {
|
||||
const { copyright, navlinks, locale } = useAppSelector((state) => {
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
|
||||
return {
|
||||
copyright: soapboxConfig.copyright,
|
||||
navlinks: (soapboxConfig.navlinks.get('homeFooter') || ImmutableList()) as ImmutableList<FooterItem>,
|
||||
locale: getSettings(state).get('locale') as string,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
|
||||
<div className='flex flex-wrap justify-center'>
|
||||
{navlinks.map((link, idx) => {
|
||||
const url = link.get('url');
|
||||
const isExternal = url.startsWith('http');
|
||||
const Comp = (isExternal ? 'a' : Link) as 'a';
|
||||
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
|
||||
|
||||
return (
|
||||
<div key={idx} className='px-5 py-2'>
|
||||
<Comp {...compProps} className='hover:underline'>
|
||||
<Text tag='span' theme='primary' size='sm'>
|
||||
{(link.getIn(['titleLocales', locale]) || link.get('title')) as string}
|
||||
</Text>
|
||||
</Comp>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 xl:mt-0'>
|
||||
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
|
@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
|
|||
import { changeSetting } from 'soapbox/actions/settings';
|
||||
import { connectPublicStream } from 'soapbox/actions/streaming';
|
||||
import { expandPublicTimeline } from 'soapbox/actions/timelines';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import Accordion from 'soapbox/features/ui/components/accordion';
|
||||
|
@ -91,14 +92,15 @@ const CommunityTimeline = () => {
|
|||
/>
|
||||
</Accordion>
|
||||
</div>}
|
||||
<Timeline
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
onRefresh={handleRefresh}
|
||||
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
|
||||
divideType='space'
|
||||
/>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Timeline
|
||||
scrollKey={`${timelineId}_timeline`}
|
||||
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
|
||||
divideType='space'
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -49,6 +49,7 @@ import {
|
|||
fetchNext,
|
||||
} from 'soapbox/actions/statuses';
|
||||
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { textForScreenReader } from 'soapbox/components/status';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
|
@ -796,23 +797,24 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
<SubNavigation message={intl.formatMessage(titleMessage, { username })} />
|
||||
</div>
|
||||
|
||||
<Stack space={2}>
|
||||
<div ref={this.setRef} className='thread'>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={this.setScrollerRef}
|
||||
onRefresh={this.handleRefresh}
|
||||
hasMore={!!this.state.next}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||
initialTopMostItemIndex={ancestorsIds.size}
|
||||
>
|
||||
{children}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
<PullToRefresh onRefresh={this.handleRefresh}>
|
||||
<Stack space={2}>
|
||||
<div ref={this.setRef} className='thread'>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={this.setScrollerRef}
|
||||
hasMore={!!this.state.next}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||
initialTopMostItemIndex={ancestorsIds.size}
|
||||
>
|
||||
{children}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
{!me && <ThreadLoginCta />}
|
||||
</Stack>
|
||||
{!me && <ThreadLoginCta />}
|
||||
</Stack>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
|||
className={classNames({ active, destructive })}
|
||||
data-method={isLogout ? 'delete' : null}
|
||||
>
|
||||
{icon && <Icon title={text} src={icon} role='presentation' tabIndex='-1' inverted />}
|
||||
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}
|
||||
<div>
|
||||
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||
<div>{meta}</div>
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import IconButton from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
|
||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class BundleModalError extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onRetry: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.props.onRetry();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onClose, intl: { formatMessage } } = this.props;
|
||||
|
||||
// Keep the markup in sync with <ModalLoading />
|
||||
// (make sure they have the same dimensions)
|
||||
return (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
|
||||
{formatMessage(messages.error)}
|
||||
</div>
|
||||
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='error-modal__nav onboarding-modal__skip'
|
||||
>
|
||||
{formatMessage(messages.close)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleModalError);
|
45
app/soapbox/features/ui/components/bundle_modal_error.tsx
Normal file
45
app/soapbox/features/ui/components/bundle_modal_error.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
|
||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
interface IBundleModalError {
|
||||
onRetry: () => void,
|
||||
onClose: () => void,
|
||||
}
|
||||
|
||||
const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleRetry = () => {
|
||||
onRetry();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
|
||||
{intl.formatMessage(messages.error)}
|
||||
</div>
|
||||
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='error-modal__nav onboarding-modal__skip'
|
||||
>
|
||||
{intl.formatMessage(messages.close)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BundleModalError;
|
|
@ -6,11 +6,9 @@ import { verifyCredentials } from 'soapbox/actions/auth';
|
|||
import { closeModal } from 'soapbox/actions/modals';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
|
||||
import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui';
|
||||
import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification';
|
||||
import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { getAccessToken } from 'soapbox/utils/auth';
|
||||
import { formatPhoneNumber } from 'soapbox/utils/phone';
|
||||
|
||||
interface IVerifySmsModal {
|
||||
onClose: (type: string) => void,
|
||||
|
@ -32,16 +30,14 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
|
|||
const isLoading = useAppSelector((state) => state.verification.isLoading);
|
||||
|
||||
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
|
||||
const [phone, setPhone] = useState<string>('');
|
||||
const [phone, setPhone] = useState<string>();
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
|
||||
|
||||
const isValid = validPhoneNumberRegex.test(phone);
|
||||
const isValid = !!phone;
|
||||
|
||||
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formattedPhone = formatPhoneNumber(event.target.value);
|
||||
|
||||
setPhone(formattedPhone);
|
||||
const onChange = useCallback((phone?: string) => {
|
||||
setPhone(phone);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (event: React.MouseEvent) => {
|
||||
|
@ -60,7 +56,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
dispatch(reRequestPhoneVerification(phone)).then(() => {
|
||||
dispatch(reRequestPhoneVerification(phone!)).then(() => {
|
||||
dispatch(
|
||||
snackbar.success(
|
||||
intl.formatMessage({
|
||||
|
@ -141,8 +137,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
|
|||
case Statuses.READY:
|
||||
return (
|
||||
<FormGroup labelText='Phone Number'>
|
||||
<Input
|
||||
type='text'
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={onChange}
|
||||
required
|
||||
|
|
|
@ -487,7 +487,7 @@ export function CreateApp() {
|
|||
}
|
||||
|
||||
export function SettingsStore() {
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers/settings_store');
|
||||
return import(/* webpackChunkName: "features/developers" */'../../developers/settings-store');
|
||||
}
|
||||
|
||||
export function TestTimeline() {
|
||||
|
|
|
@ -29,7 +29,7 @@ describe('<SmsVerification />', () => {
|
|||
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
|
||||
await waitFor(() => {
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button'), {
|
||||
screen.getByRole('button', { name: 'Next' }), {
|
||||
preventDefault: () => {},
|
||||
},
|
||||
);
|
||||
|
@ -56,7 +56,7 @@ describe('<SmsVerification />', () => {
|
|||
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
|
||||
await waitFor(() => {
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button'), {
|
||||
screen.getByRole('button', { name: 'Next' }), {
|
||||
preventDefault: () => {},
|
||||
},
|
||||
);
|
||||
|
@ -90,7 +90,7 @@ describe('<SmsVerification />', () => {
|
|||
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
|
||||
await waitFor(() => {
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button'), {
|
||||
screen.getByRole('button', { name: 'Next' }), {
|
||||
preventDefault: () => {},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -5,9 +5,8 @@ import OtpInput from 'react-otp-input';
|
|||
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
|
||||
import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui';
|
||||
import { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { formatPhoneNumber } from 'soapbox/utils/phone';
|
||||
|
||||
const Statuses = {
|
||||
IDLE: 'IDLE',
|
||||
|
@ -15,25 +14,21 @@ const Statuses = {
|
|||
FAIL: 'FAIL',
|
||||
};
|
||||
|
||||
const validPhoneNumberRegex = /^\+1\s\(\d{3}\)\s\d{3}-\d{4}/;
|
||||
|
||||
const SmsVerification = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean;
|
||||
|
||||
const [phone, setPhone] = React.useState('');
|
||||
const [phone, setPhone] = React.useState<string>();
|
||||
const [status, setStatus] = React.useState(Statuses.IDLE);
|
||||
const [verificationCode, setVerificationCode] = React.useState('');
|
||||
const [requestedAnother, setAlreadyRequestedAnother] = React.useState(false);
|
||||
|
||||
const isValid = validPhoneNumberRegex.test(phone);
|
||||
const isValid = !!phone;
|
||||
|
||||
const onChange = React.useCallback((event) => {
|
||||
const formattedPhone = formatPhoneNumber(event.target.value);
|
||||
|
||||
setPhone(formattedPhone);
|
||||
const onChange = React.useCallback((phone?: string) => {
|
||||
setPhone(phone);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = React.useCallback((event) => {
|
||||
|
@ -52,7 +47,7 @@ const SmsVerification = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
dispatch(requestPhoneVerification(phone)).then(() => {
|
||||
dispatch(requestPhoneVerification(phone!)).then(() => {
|
||||
dispatch(
|
||||
snackbar.success(
|
||||
intl.formatMessage({
|
||||
|
@ -147,8 +142,7 @@ const SmsVerification = () => {
|
|||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText='Phone Number'>
|
||||
<Input
|
||||
type='text'
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={onChange}
|
||||
required
|
||||
|
@ -164,4 +158,4 @@ const SmsVerification = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export { SmsVerification as default, validPhoneNumberRegex };
|
||||
export { SmsVerification as default };
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "فك التدبيس من الصفحة التعريفية",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "إلغاء الاقتراح",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Desfixar del perfil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "নিজের পাতা থেকে পিন করে রাখাটির পিন খুলতে",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "সাহায্যের পরামর্শগুলো সরাতে",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Deslliga del perfil",
|
||||
"status_list.queue_label": "Fes clic per veure {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Descartar suggeriment",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Xats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Spuntarulà da u prufile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Righjittà a pruposta",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Odepnout z profilu",
|
||||
"status_list.queue_label": "Klikněte aby pro zobrazení {count} nových {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Odmítnout návrh",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chaty",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Dadbinio o'r proffil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Diswyddo",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Frigør fra profil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Afvis foreslag",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -5747,7 +5747,7 @@
|
|||
"id": "confirmations.block.message"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "One or more posts is unavailable.",
|
||||
"defaultMessage": "One or more posts are unavailable.",
|
||||
"id": "statuses.tombstone"
|
||||
}
|
||||
],
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Ξεκαρφίτσωσε από το προφίλ",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Απόρριψη πρότασης",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1030,7 +1030,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Depingli de profilo",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Forigi la proponon",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Desmarcar del perfil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Descartar sugerencia",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Dejar de fijar",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Descartar sugerencia",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Kinnita profiililt lahti",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Eira soovitust",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Desfinkatu profiletik",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Errefusatu proposamena",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "برداشتن نوشتهٔ ثابت نمایه",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "پیشنهاد را نادیده بگیر",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Irrota profiilista",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Retirer du profil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Rejeter la suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Despegar do perfil",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Rexeitar suxestión",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Kitűzés eltávolítása a profilodról",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Javaslat elvetése",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Հանել անձնական էջից",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1022,7 +1022,7 @@
|
|||
"status.unpin": "Non fissare in cima al profilo",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Elimina suggerimento",
|
||||
"tabs_bar.all": "Tutto",
|
||||
"tabs_bar.chats": "Chat",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "プロフィールへの固定を解除",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "隠す",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "チャット",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "პროფილიდან პინის მოშორება",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Профильден алып тастау",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Өткізіп жіберу",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "고정 해제",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "추천 지우기",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
|
@ -1020,7 +1020,7 @@
|
|||
"status.unpin": "Unpin from profile",
|
||||
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
|
||||
"statuses.quote_tombstone": "Post is unavailable.",
|
||||
"statuses.tombstone": "One or more posts is unavailable.",
|
||||
"statuses.tombstone": "One or more posts are unavailable.",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tabs_bar.all": "All",
|
||||
"tabs_bar.chats": "Chats",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue