Merge remote-tracking branch 'origin/next' into next-theme-picker
This commit is contained in:
commit
3e988cb3a3
|
@ -141,6 +141,7 @@ module.exports = {
|
|||
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
|
||||
'react/jsx-indent': ['error', 2],
|
||||
// 'react/jsx-no-bind': ['error'],
|
||||
'react/jsx-no-comment-textnodes': 'error',
|
||||
'react/jsx-no-duplicate-props': 'error',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
image: node:14
|
||||
image: node:16
|
||||
|
||||
variables:
|
||||
NODE_ENV: test
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 14.17.6
|
||||
nodejs 16.14.2
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"uri": "pixelfed.social",
|
||||
"title": "pixelfed",
|
||||
"short_description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms",
|
||||
"description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms",
|
||||
"email": "hello@pixelfed.org",
|
||||
"version": "2.7.2 (compatible; Pixelfed 0.11.2)",
|
||||
"urls": {
|
||||
"streaming_api": "wss://pixelfed.social"
|
||||
},
|
||||
"stats": {
|
||||
"user_count": 45061,
|
||||
"status_count": 301357,
|
||||
"domain_count": 5028
|
||||
},
|
||||
"thumbnail": "https://pixelfed.social/img/pixelfed-icon-color.png",
|
||||
"languages": [
|
||||
"en"
|
||||
],
|
||||
"registrations": true,
|
||||
"approval_required": false,
|
||||
"contact_account": {
|
||||
"id": "1",
|
||||
"username": "admin",
|
||||
"acct": "admin",
|
||||
"display_name": "Admin",
|
||||
"discoverable": true,
|
||||
"locked": false,
|
||||
"followers_count": 419,
|
||||
"following_count": 2,
|
||||
"statuses_count": 6,
|
||||
"note": "pixelfed.social Admin. Managed by @dansup",
|
||||
"url": "https://pixelfed.social/admin",
|
||||
"avatar": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4",
|
||||
"created_at": "2018-06-01T03:54:08.000000Z",
|
||||
"avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4",
|
||||
"bot": false,
|
||||
"emojis": [],
|
||||
"fields": [],
|
||||
"header": "https://pixelfed.social/storage/headers/missing.png",
|
||||
"header_static": "https://pixelfed.social/storage/headers/missing.png",
|
||||
"last_status_at": null
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "Sexually explicit or violent media must be marked as sensitive when posting"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"text": "No incitement of violence or promotion of violent ideologies"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"text": "No harassment, dogpiling or doxxing of other users"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"text": "No content illegal in United States"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -9,7 +9,7 @@ export const __stub = (func: Function) => mocks.push(func);
|
|||
export const __clear = (): Function[] => mocks = [];
|
||||
|
||||
const setupMock = (axios: AxiosInstance) => {
|
||||
const mock = new MockAdapter(axios);
|
||||
const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
|
||||
mocks.map(func => func(mock));
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { rootState } from '../../jest/test-helpers';
|
||||
import { getSoapboxConfig } from '../soapbox';
|
||||
|
||||
const ASCII_HEART = '❤'; // '\u2764\uFE0F'
|
||||
const RED_HEART_RGI = '❤️'; // '\u2764'
|
||||
|
||||
describe('getSoapboxConfig()', () => {
|
||||
it('returns RGI heart on Pleroma > 2.3', () => {
|
||||
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.3.0)');
|
||||
expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(true);
|
||||
expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an ASCII heart on Pleroma < 2.3', () => {
|
||||
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.0.0)');
|
||||
expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(true);
|
||||
expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(false);
|
||||
});
|
||||
});
|
|
@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps';
|
|||
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
|
||||
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { custom } from 'soapbox/custom';
|
||||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
|
@ -39,12 +40,14 @@ export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST';
|
|||
export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS';
|
||||
export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL';
|
||||
|
||||
const customApp = custom('app');
|
||||
|
||||
export const messages = defineMessages({
|
||||
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
|
||||
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
|
||||
});
|
||||
|
||||
const noOp = () => () => new Promise(f => f());
|
||||
const noOp = () => new Promise(f => f());
|
||||
|
||||
const getScopes = state => {
|
||||
const instance = state.get('instance');
|
||||
|
@ -54,12 +57,23 @@ const getScopes = state => {
|
|||
|
||||
function createAppAndToken() {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(createAuthApp()).then(() => {
|
||||
return dispatch(getAuthApp()).then(() => {
|
||||
return dispatch(createAppToken());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an auth app, or use it from build config */
|
||||
function getAuthApp() {
|
||||
return (dispatch, getState) => {
|
||||
if (customApp?.client_secret) {
|
||||
return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp }));
|
||||
} else {
|
||||
return dispatch(createAuthApp());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthApp() {
|
||||
return (dispatch, getState) => {
|
||||
const params = {
|
||||
|
@ -117,7 +131,7 @@ export function refreshUserToken() {
|
|||
const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']);
|
||||
const app = getState().getIn(['auth', 'app']);
|
||||
|
||||
if (!refreshToken) return dispatch(noOp());
|
||||
if (!refreshToken) return dispatch(noOp);
|
||||
|
||||
const params = {
|
||||
client_id: app.get('client_id'),
|
||||
|
@ -200,7 +214,7 @@ export function loadCredentials(token, accountUrl) {
|
|||
|
||||
export function logIn(intl, username, password) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(createAuthApp()).then(() => {
|
||||
return dispatch(getAuthApp()).then(() => {
|
||||
return dispatch(createUserToken(username, password));
|
||||
}).catch(error => {
|
||||
if (error.response.data.error === 'mfa_required') {
|
||||
|
|
|
@ -93,7 +93,7 @@ const isBroken = status => {
|
|||
// https://gitlab.com/soapbox-pub/soapbox/-/issues/28
|
||||
if (status.reblog && !status.reblog.account.id) return true;
|
||||
return false;
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
export const MODAL_OPEN = 'MODAL_OPEN';
|
||||
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
||||
|
||||
export function openModal(type, props) {
|
||||
/** Open a modal of the given type */
|
||||
export function openModal(type: string, props?: any) {
|
||||
return {
|
||||
type: MODAL_OPEN,
|
||||
modalType: type,
|
||||
|
@ -9,7 +10,8 @@ export function openModal(type, props) {
|
|||
};
|
||||
}
|
||||
|
||||
export function closeModal(type) {
|
||||
/** Close the modal */
|
||||
export function closeModal(type: string) {
|
||||
return {
|
||||
type: MODAL_CLOSE,
|
||||
modalType: type,
|
|
@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
|
|||
}).catch(console.error);
|
||||
}).catch(console.error);
|
||||
}
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { getHost } from 'soapbox/actions/instance';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import { removeVS16s } from 'soapbox/utils/emoji';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import api, { staticClient } from '../api';
|
||||
|
@ -15,38 +15,24 @@ export const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST'
|
|||
export const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS';
|
||||
export const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL';
|
||||
|
||||
const allowedEmoji = ImmutableList([
|
||||
'👍',
|
||||
'❤',
|
||||
'😆',
|
||||
'😮',
|
||||
'😢',
|
||||
'😩',
|
||||
]);
|
||||
|
||||
// https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
const allowedEmojiRGI = ImmutableList([
|
||||
'👍',
|
||||
'❤️',
|
||||
'😆',
|
||||
'😮',
|
||||
'😢',
|
||||
'😩',
|
||||
]);
|
||||
|
||||
export const makeDefaultConfig = features => {
|
||||
return ImmutableMap({
|
||||
allowedEmoji: features.emojiReactsRGI ? allowedEmojiRGI : allowedEmoji,
|
||||
displayFqn: Boolean(features.federating),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSoapboxConfig = createSelector([
|
||||
state => state.get('soapbox'),
|
||||
state => getFeatures(state.get('instance')),
|
||||
state => state.soapbox,
|
||||
state => getFeatures(state.instance),
|
||||
], (soapbox, features) => {
|
||||
const defaultConfig = makeDefaultConfig(features);
|
||||
return normalizeSoapboxConfig(soapbox).merge(defaultConfig);
|
||||
// Do some additional normalization with the state
|
||||
return normalizeSoapboxConfig(soapbox).withMutations(soapboxConfig => {
|
||||
|
||||
// If displayFqn isn't set, infer it from federation
|
||||
if (soapbox.get('displayFqn') === undefined) {
|
||||
soapboxConfig.set('displayFqn', features.federating);
|
||||
}
|
||||
|
||||
// If RGI reacts aren't supported, strip VS16s
|
||||
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
if (!features.emojiReactsRGI) {
|
||||
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export function rememberSoapboxConfig(host) {
|
||||
|
|
|
@ -31,7 +31,7 @@ const getToken = (state: RootState, authType: string) => {
|
|||
const maybeParseJSON = (data: string) => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch(Exception) {
|
||||
} catch (Exception) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
export default function compareId(id1, id2) {
|
||||
export default function compareId(id1: string, id2: string) {
|
||||
if (id1 === id2) {
|
||||
return 0;
|
||||
}
|
|
@ -82,7 +82,7 @@ class BirthdayReminders extends ImmutablePureComponent {
|
|||
);
|
||||
|
||||
if (birthdays.size === 1) {
|
||||
return <FormattedMessage id='notification.birthday' defaultMessage='{name} has birthday today' values={{ name: link }} />;
|
||||
return <FormattedMessage id='notification.birthday' defaultMessage='{name} has a birthday today' values={{ name: link }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -109,7 +109,7 @@ class BirthdayReminders extends ImmutablePureComponent {
|
|||
const { intl, birthdays, account } = this.props;
|
||||
|
||||
if (birthdays.size === 1) {
|
||||
return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has birthday today' }, { name: account.get('display_name') });
|
||||
return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has a birthday today' }, { name: account.get('display_name') });
|
||||
}
|
||||
|
||||
return intl.formatMessage(
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import IconButton from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
domain: PropTypes.string,
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleDomainUnblock = () => {
|
||||
this.props.onUnblockDomain(this.props.domain);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { domain, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='domain'>
|
||||
<div className='domain__wrapper'>
|
||||
<span className='domain__domain-name'>
|
||||
<strong>{domain}</strong>
|
||||
</span>
|
||||
|
||||
<div className='domain__buttons'>
|
||||
<IconButton active src={require('@tabler/icons/icons/lock-open.svg')} title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { unblockDomain } from 'soapbox/actions/domain_blocks';
|
||||
|
||||
import IconButton from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
interface IDomain {
|
||||
domain: string,
|
||||
}
|
||||
|
||||
const Domain: React.FC<IDomain> = ({ domain }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
// const onBlockDomain = () => {
|
||||
// dispatch(openModal('CONFIRM', {
|
||||
// icon: require('@tabler/icons/icons/ban.svg'),
|
||||
// heading: <FormattedMessage id='confirmations.domain_block.heading' defaultMessage='Block {domain}' values={{ domain }} />,
|
||||
// message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
// confirm: intl.formatMessage(messages.blockDomainConfirm),
|
||||
// onConfirm: () => dispatch(blockDomain(domain)),
|
||||
// }));
|
||||
// }
|
||||
|
||||
const handleDomainUnblock = () => {
|
||||
dispatch(unblockDomain(domain));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='domain'>
|
||||
<div className='domain__wrapper'>
|
||||
<span className='domain__domain-name'>
|
||||
<strong>{domain}</strong>
|
||||
</span>
|
||||
|
||||
<div className='domain__buttons'>
|
||||
<IconButton active src={require('@tabler/icons/icons/lock-open.svg')} title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={handleDomainUnblock} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Domain;
|
|
@ -278,7 +278,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
|
|||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else if(onOpen) {
|
||||
} else if (onOpen) {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
|
||||
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
||||
|
||||
interface IEmojiButtonWrapper {
|
||||
statusId: string,
|
||||
children: JSX.Element,
|
||||
}
|
||||
|
||||
/** Provides emoji reaction functionality to the underlying button component */
|
||||
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const dispatch = useDispatch();
|
||||
const ownAccount = useOwnAccount();
|
||||
const status = useAppSelector(state => state.statuses.get(statusId));
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
// const [focused, setFocused] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleReact = (emoji: string): void => {
|
||||
if (ownAccount) {
|
||||
dispatch(simpleEmojiReact(status, emoji));
|
||||
} else {
|
||||
dispatch(openModal('UNAUTHORIZED', {
|
||||
action: 'FAVOURITE',
|
||||
ap_id: status.url,
|
||||
}));
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (visible) {
|
||||
handleReact(meEmojiReact);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
} else {
|
||||
handleReact(meEmojiReact);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
|
||||
// setFocused(false);
|
||||
// };
|
||||
|
||||
const selector = (
|
||||
<div
|
||||
className={classNames('fixed z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={popperRef}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<EmojiSelector
|
||||
emojis={soapboxConfig.allowedEmoji}
|
||||
onReact={handleReact}
|
||||
// focused={focused}
|
||||
// onUnfocus={handleUnfocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{React.cloneElement(children, {
|
||||
onClick: handleClick,
|
||||
ref,
|
||||
})}
|
||||
|
||||
{selector}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiButtonWrapper;
|
|
@ -1,63 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
interface IHoverable {
|
||||
component: JSX.Element,
|
||||
}
|
||||
|
||||
/** Wrapper to render a given component when hovered */
|
||||
const Hoverable: React.FC<IHoverable> = ({
|
||||
component,
|
||||
children,
|
||||
}): JSX.Element => {
|
||||
|
||||
const [portalActive, setPortalActive] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setPortalActive(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setPortalActive(false);
|
||||
};
|
||||
|
||||
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
|
||||
placement: 'top-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
|
||||
<div
|
||||
className={classNames('fixed z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !portalActive,
|
||||
})}
|
||||
ref={popperRef}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{component}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hoverable;
|
|
@ -6,8 +6,7 @@ import { connect } from 'react-redux';
|
|||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||
import EmojiSelector from 'soapbox/components/emoji_selector';
|
||||
import Hoverable from 'soapbox/components/hoverable';
|
||||
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
|
||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
|
@ -554,7 +553,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
}
|
||||
|
||||
render() {
|
||||
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props;
|
||||
const { status, intl, allowedEmoji, features, me } = this.props;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
|
||||
|
||||
|
@ -578,9 +577,10 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
'😮': messages.reactionOpenMouth,
|
||||
'😢': messages.reactionCry,
|
||||
'😩': messages.reactionWeary,
|
||||
'': messages.favourite,
|
||||
};
|
||||
|
||||
const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite);
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite);
|
||||
|
||||
const menu = this._makeMenu(publicStatus);
|
||||
let reblogIcon = require('@tabler/icons/icons/repeat.svg');
|
||||
|
@ -640,24 +640,15 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
)}
|
||||
|
||||
{features.emojiReacts ? (
|
||||
<Hoverable
|
||||
component={(
|
||||
<EmojiSelector
|
||||
onReact={this.handleReact}
|
||||
focused={emojiSelectorFocused}
|
||||
onUnfocus={handleEmojiSelectorUnfocus}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
icon={require('@tabler/icons/icons/thumb-up.svg')}
|
||||
color='accent'
|
||||
onClick={this.handleLikeButtonClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
count={emojiReactCount}
|
||||
/>
|
||||
</Hoverable>
|
||||
</EmojiButtonWrapper>
|
||||
): (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
|
|
|
@ -87,7 +87,7 @@ class StatusContent extends React.PureComponent {
|
|||
&& this.state.collapsed === null
|
||||
&& this.props.status.get('spoiler_text').length === 0
|
||||
) {
|
||||
if (node.clientHeight > MAX_HEIGHT){
|
||||
if (node.clientHeight > MAX_HEIGHT) {
|
||||
this.setState({ collapsed: true });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
|
|||
<NavLink to={to} exact={exact} className='thumb-navigation__link'>
|
||||
{count !== undefined ? (
|
||||
<IconWithCounter
|
||||
src={require('@tabler/icons/icons/messages.svg')}
|
||||
src={src}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600 dark:text-gray-300': !active,
|
||||
|
|
|
@ -19,7 +19,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
|||
};
|
||||
|
||||
interface IEmojiSelector {
|
||||
emojis: string[],
|
||||
emojis: Iterable<string>,
|
||||
onReact: (emoji: string) => void,
|
||||
visible?: boolean,
|
||||
focused?: boolean,
|
||||
|
@ -40,7 +40,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = fa
|
|||
space={2}
|
||||
className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')}
|
||||
>
|
||||
{emojis.map((emoji, i) => (
|
||||
{Array.from(emojis).map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
|
|
|
@ -1,34 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
import { removeVS16s, toCodePoints } from 'soapbox/utils/emoji';
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
// Taken from twemoji-parser
|
||||
// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js
|
||||
const removeVS16s = (rawEmoji: string): string => {
|
||||
const vs16RegExp = /\uFE0F/g;
|
||||
const zeroWidthJoiner = String.fromCharCode(0x200d);
|
||||
return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji;
|
||||
};
|
||||
|
||||
const toCodePoints = (unicodeSurrogates: string): string[] => {
|
||||
const points = [];
|
||||
let char = 0;
|
||||
let previous = 0;
|
||||
let i = 0;
|
||||
while (i < unicodeSurrogates.length) {
|
||||
char = unicodeSurrogates.charCodeAt(i++);
|
||||
if (previous) {
|
||||
points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16));
|
||||
previous = 0;
|
||||
} else if (char > 0xd800 && char <= 0xdbff) {
|
||||
previous = char;
|
||||
} else {
|
||||
points.push(char.toString(16));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
interface IEmoji extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
emoji: string,
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, size = 24, className }): JSX.El
|
|||
loader={loader}
|
||||
data-testid='svg-icon'
|
||||
>
|
||||
/* If the fetch fails, fall back to displaying the loader */
|
||||
{/* If the fetch fails, fall back to displaying the loader */}
|
||||
{loader}
|
||||
</InlineSVG>
|
||||
);
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { blockDomain, unblockDomain } from '../actions/domain_blocks';
|
||||
import { openModal } from '../actions/modals';
|
||||
import Domain from '../components/domain';
|
||||
|
||||
const messages = defineMessages({
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const mapStateToProps = () => ({});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onBlockDomain(domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/icons/ban.svg'),
|
||||
heading: <FormattedMessage id='confirmations.domain_block.heading' defaultMessage='Block {domain}' values={{ domain }} />,
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockDomainConfirm),
|
||||
onConfirm: () => dispatch(blockDomain(domain)),
|
||||
}));
|
||||
},
|
||||
|
||||
onUnblockDomain(domain) {
|
||||
dispatch(unblockDomain(domain));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));
|
|
@ -1,12 +1,13 @@
|
|||
/**
|
||||
* Functions for dealing with custom build configuration.
|
||||
*/
|
||||
import { NODE_ENV } from 'soapbox/build_config';
|
||||
import * as BuildConfig from 'soapbox/build_config';
|
||||
|
||||
/** Require a custom JSON file if it exists */
|
||||
export const custom = (filename, fallback = {}) => {
|
||||
if (NODE_ENV === 'test') return fallback;
|
||||
export const custom = (filename: string, fallback: any = {}): any => {
|
||||
if (BuildConfig.NODE_ENV === 'test') return fallback;
|
||||
|
||||
// @ts-ignore: yes it does
|
||||
const context = require.context('custom', false, /\.json$/);
|
||||
const path = `./${filename}.json`;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import AvatarOverlay from '../../../components/avatar_overlay';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
|
||||
export default class MovedNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
from: ImmutablePropTypes.map.isRequired,
|
||||
to: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { from, to } = this.props;
|
||||
const displayNameHtml = { __html: from.get('display_name_html') };
|
||||
|
||||
return (
|
||||
<div className='account__moved-note'>
|
||||
<div className='account__moved-note__message'>
|
||||
<div className='account__moved-note__icon-wrapper'><Icon src={require('feather-icons/dist/icons/briefcase.svg')} className='account__moved-note__icon' fixedWidth /></div>
|
||||
<FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
|
||||
</div>
|
||||
|
||||
<NavLink to={`/@${this.props.to.get('acct')}`} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
||||
<DisplayName account={to} />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import AvatarOverlay from 'soapbox/components/avatar_overlay';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IMovedNote {
|
||||
from: AccountEntity,
|
||||
to: AccountEntity,
|
||||
}
|
||||
|
||||
const MovedNote: React.FC<IMovedNote> = ({ from, to }) => {
|
||||
const displayNameHtml = { __html: from.display_name_html };
|
||||
|
||||
return (
|
||||
<div className='account__moved-note'>
|
||||
<div className='account__moved-note__message'>
|
||||
<div className='account__moved-note__icon-wrapper'><Icon src={require('feather-icons/dist/icons/briefcase.svg')} className='account__moved-note__icon' fixedWidth /></div>
|
||||
<FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
|
||||
</div>
|
||||
|
||||
<NavLink to={`/@${to.acct}`} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
||||
<DisplayName account={to} />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovedNote;
|
|
@ -255,7 +255,7 @@ class Audio extends React.PureComponent {
|
|||
handleMouseVolSlide = throttle(e => {
|
||||
const { x } = getPointerPosition(this.volume, e);
|
||||
|
||||
if(!isNaN(x)) {
|
||||
if (!isNaN(x)) {
|
||||
this.setState({ volume: x }, () => {
|
||||
this.audio.volume = x;
|
||||
});
|
||||
|
|
|
@ -1,88 +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 } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import Permalink from 'soapbox/components/permalink';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => {
|
||||
const account = getAccount(state, accountId);
|
||||
|
||||
return {
|
||||
account,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
account: ImmutablePropTypes.record,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
added: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { account, accountId } = this.props;
|
||||
|
||||
if (accountId && !account) {
|
||||
this.props.fetchAccount(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { account, intl } = this.props;
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const birthday = account.get('birthday');
|
||||
if (!birthday) return null;
|
||||
|
||||
const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink className='account__display-name' title={account.get('acct')} href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
|
||||
</div>
|
||||
</Permalink>
|
||||
<div
|
||||
className='account__birthday'
|
||||
title={intl.formatMessage(messages.birthday, {
|
||||
date: formattedBirthday,
|
||||
})}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/ballon.svg')} />
|
||||
{formattedBirthday}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import Permalink from 'soapbox/components/permalink';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' },
|
||||
});
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
interface IAccount {
|
||||
accountId: string,
|
||||
fetchAccount: (id: string) => void,
|
||||
}
|
||||
|
||||
const Account: React.FC<IAccount> = ({ accountId, fetchAccount }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !account) {
|
||||
fetchAccount(accountId);
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const birthday = account.get('birthday');
|
||||
if (!birthday) return null;
|
||||
|
||||
const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink className='account__display-name' title={account.get('acct')} href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
|
||||
</div>
|
||||
</Permalink>
|
||||
<div
|
||||
className='account__birthday'
|
||||
title={intl.formatMessage(messages.birthday, {
|
||||
date: formattedBirthday,
|
||||
})}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/icons/ballon.svg')} />
|
||||
{formattedBirthday}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
|
@ -1,74 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 { Column, Spinner } from 'soapbox/components/ui';
|
||||
|
||||
import { fetchBlocks, expandBlocks } from '../../actions/blocks';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'blocks', 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Blocks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.orderedSet,
|
||||
hasMore: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchBlocks());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBlocks());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, accountIds, hasMore } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='blocks'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchBlocks, expandBlocks } from 'soapbox/actions/blocks';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Column, Spinner } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandBlocks());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const Blocks: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const accountIds = useAppSelector((state) => state.user_lists.getIn(['blocks', 'items']));
|
||||
const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['blocks', 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchBlocks());
|
||||
}, []);
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='blocks'
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-4'
|
||||
>
|
||||
{accountIds.map((id: string) =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Blocks;
|
|
@ -1,83 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
|
||||
import StatusList from '../../components/status_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
|
||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Bookmarks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
statusIds: ImmutablePropTypes.orderedSet.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
fetchData = () => {
|
||||
const { dispatch } = this.props;
|
||||
return dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true })
|
||||
|
||||
handleRefresh = () => {
|
||||
return this.fetchData();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column transparent>
|
||||
<SubNavigation message={intl.formatMessage(messages.heading)} />
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
onRefresh={this.handleRefresh}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
divideType='space'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
|
||||
import StatusList from '../../components/status_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const Bookmarks: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items']));
|
||||
const isLoading = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'isLoading'], true));
|
||||
const hasMore = useAppSelector((state) => !!state.status_lists.getIn(['bookmarks', 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(fetchBookmarkedStatuses());
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column transparent>
|
||||
<SubNavigation message={intl.formatMessage(messages.heading)} />
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey={'bookmarked_statuses'}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
onRefresh={handleRefresh}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType='space'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmarks;
|
|
@ -1,61 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import { changeSetting, getSettings } from 'soapbox/actions/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' },
|
||||
switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
checked: getSettings(state).getIn(['chats', 'sound'], false),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
toggleAudio(setting) {
|
||||
dispatch(changeSetting(['chats', 'sound'], setting));
|
||||
},
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
class AudioToggle extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
toggleAudio: PropTypes.func,
|
||||
showLabel: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleToggleAudio = () => {
|
||||
this.props.toggleAudio(!this.props.checked);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, checked, showLabel } = this.props;
|
||||
const id = 'chats-audio-toggle';
|
||||
const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn);
|
||||
|
||||
return (
|
||||
<div className='audio-toggle react-toggle--mini'>
|
||||
<div className='setting-toggle' aria-label={label}>
|
||||
<Toggle
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={this.handleToggleAudio}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
{showLabel && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import { changeSetting, getSettings } from 'soapbox/actions/settings';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' },
|
||||
switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' },
|
||||
});
|
||||
|
||||
interface IAudioToggle {
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const AudioToggle: React.FC<IAudioToggle> = ({ showLabel }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound']));
|
||||
|
||||
const handleToggleAudio = () => {
|
||||
dispatch(changeSetting(['chats', 'sound'], !checked));
|
||||
};
|
||||
|
||||
const id = 'chats-audio-toggle';
|
||||
const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn);
|
||||
|
||||
return (
|
||||
<div className='audio-toggle react-toggle--mini'>
|
||||
<div className='setting-toggle' aria-label={label}>
|
||||
<Toggle
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={handleToggleAudio}
|
||||
// onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
{showLabel && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioToggle;
|
|
@ -1,87 +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 { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getChat = makeGetChat();
|
||||
|
||||
const mapStateToProps = (state, { chatId }) => {
|
||||
const chat = state.getIn(['chats', 'items', chatId]);
|
||||
|
||||
return {
|
||||
chat: chat ? getChat(state, chat.toJS()) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
class Chat extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
chatId: PropTypes.string.isRequired,
|
||||
chat: ImmutablePropTypes.map,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onClick(this.props.chat);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chat } = this.props;
|
||||
if (!chat) return null;
|
||||
const account = chat.get('account');
|
||||
const unreadCount = chat.get('unread');
|
||||
const content = chat.getIn(['last_message', 'content']);
|
||||
const attachment = chat.getIn(['last_message', 'attachment']);
|
||||
const image = attachment && attachment.getIn(['pleroma', 'mime_type'], '').startsWith('image/');
|
||||
const parsedContent = content ? emojify(content) : '';
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<button className='floating-link' onClick={this.handleClick} />
|
||||
<div className='account__wrapper'>
|
||||
<div key={account.get('id')} className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
<DisplayName account={account} />
|
||||
{attachment && (
|
||||
<Icon
|
||||
className='chat__attachment-icon'
|
||||
src={image ? require('@tabler/icons/icons/photo.svg') : require('@tabler/icons/icons/paperclip.svg')}
|
||||
/>
|
||||
)}
|
||||
{content ? (
|
||||
<span
|
||||
className='chat__last-message'
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
/>
|
||||
) : attachment && (
|
||||
<span
|
||||
className='chat__last-message attachment'
|
||||
>
|
||||
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getChat = makeGetChat();
|
||||
|
||||
interface IChat {
|
||||
chatId: string,
|
||||
onClick: (chat: any) => void,
|
||||
}
|
||||
|
||||
const Chat: React.FC<IChat> = ({ chatId, onClick }) => {
|
||||
const chat = useAppSelector((state) => {
|
||||
const chat = state.chats.getIn(['items', chatId]);
|
||||
return chat ? getChat(state, (chat as any).toJS()) : undefined;
|
||||
}) as ChatEntity;
|
||||
|
||||
const account = chat.account as AccountEntity;
|
||||
if (!chat || !account) return null;
|
||||
const unreadCount = chat.unread;
|
||||
const content = chat.getIn(['last_message', 'content']);
|
||||
const attachment = chat.getIn(['last_message', 'attachment']);
|
||||
const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/');
|
||||
const parsedContent = content ? emojify(content) : '';
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<button className='floating-link' onClick={() => onClick(chat)} />
|
||||
<div className='account__wrapper'>
|
||||
<div key={account.id} className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
<DisplayName account={account} />
|
||||
{attachment && (
|
||||
<Icon
|
||||
className='chat__attachment-icon'
|
||||
src={image ? require('@tabler/icons/icons/photo.svg') : require('@tabler/icons/icons/paperclip.svg')}
|
||||
/>
|
||||
)}
|
||||
{content ? (
|
||||
<span
|
||||
className='chat__last-message'
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
/>
|
||||
) : attachment && (
|
||||
<span
|
||||
className='chat__last-message attachment'
|
||||
>
|
||||
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
|
||||
</span>
|
||||
)}
|
||||
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
|
@ -1,99 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { expandChats } from 'soapbox/actions/chats';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat';
|
||||
|
||||
import Chat from './chat';
|
||||
|
||||
const messages = defineMessages({
|
||||
emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' },
|
||||
});
|
||||
|
||||
const getSortedChatIds = chats => (
|
||||
chats
|
||||
.toList()
|
||||
.sort(chatDateComparator)
|
||||
.map(chat => chat.get('id'))
|
||||
);
|
||||
|
||||
const chatDateComparator = (chatA, chatB) => {
|
||||
// Sort most recently updated chats at the top
|
||||
const a = new Date(chatA.get('updated_at'));
|
||||
const b = new Date(chatB.get('updated_at'));
|
||||
|
||||
if (a === b) return 0;
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const sortedChatIdsSelector = createSelector(
|
||||
[getSortedChatIds],
|
||||
chats => chats,
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])),
|
||||
hasMore: !!state.getIn(['chats', 'next']),
|
||||
isLoading: state.getIn(['chats', 'loading']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
class ChatList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
chatIds: ImmutablePropTypes.list,
|
||||
onClickChat: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
hasMore: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandChats());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, chatIds, hasMore, isLoading } = this.props;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
className='chat-list'
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && chatIds.size === 0}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
onRefresh={this.props.onRefresh}
|
||||
placeholderComponent={PlaceholderChat}
|
||||
placeholderCount={20}
|
||||
>
|
||||
{chatIds.map(chatId => (
|
||||
<div key={chatId} className='chat-list-item'>
|
||||
<Chat
|
||||
chatId={chatId}
|
||||
onClick={this.props.onClickChat}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { expandChats } from 'soapbox/actions/chats';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Chat from './chat';
|
||||
|
||||
const messages = defineMessages({
|
||||
emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandChats());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const getSortedChatIds = (chats: ImmutableMap<string, any>) => (
|
||||
chats
|
||||
.toList()
|
||||
.sort(chatDateComparator)
|
||||
.map(chat => chat.id)
|
||||
);
|
||||
|
||||
const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => {
|
||||
// Sort most recently updated chats at the top
|
||||
const a = new Date(chatA.updated_at);
|
||||
const b = new Date(chatB.updated_at);
|
||||
|
||||
if (a === b) return 0;
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedChatIdsSelector = createSelector(
|
||||
[getSortedChatIds],
|
||||
chats => chats,
|
||||
);
|
||||
|
||||
interface IChatList {
|
||||
onClickChat: (chat: any) => void,
|
||||
onRefresh: () => void,
|
||||
}
|
||||
|
||||
const ChatList: React.FC<IChatList> = ({ onClickChat, onRefresh }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.get('items')));
|
||||
const hasMore = useAppSelector(state => !!state.chats.get('next'));
|
||||
const isLoading = useAppSelector(state => state.chats.get('isLoading'));
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
className='chat-list'
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && chatIds.size === 0}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
onRefresh={onRefresh}
|
||||
placeholderComponent={PlaceholderChat}
|
||||
placeholderCount={20}
|
||||
>
|
||||
{chatIds.map((chatId: string) => (
|
||||
<div key={chatId} className='chat-list-item'>
|
||||
<Chat
|
||||
chatId={chatId}
|
||||
onClick={onClickChat}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatList;
|
|
@ -1,66 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { fetchChats, launchChat } from 'soapbox/actions/chats';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
|
||||
|
||||
import { Column } from '../../components/ui';
|
||||
|
||||
import ChatList from './components/chat_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
});
|
||||
|
||||
export default @connect()
|
||||
@injectIntl
|
||||
@withRouter
|
||||
class ChatIndex extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
handleSuggestion = accountId => {
|
||||
this.props.dispatch(launchChat(accountId, this.props.history, true));
|
||||
}
|
||||
|
||||
handleClickChat = (chat) => {
|
||||
this.props.history.push(`/chats/${chat.get('id')}`);
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
const { dispatch } = this.props;
|
||||
return dispatch(fetchChats());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<div className='column__switch'>
|
||||
<AudioToggle />
|
||||
</div>
|
||||
|
||||
<AccountSearch
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onSelected={this.handleSuggestion}
|
||||
/>
|
||||
|
||||
<ChatList
|
||||
onClickChat={this.handleClickChat}
|
||||
onRefresh={this.handleRefresh}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { fetchChats, launchChat } from 'soapbox/actions/chats';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio_toggle';
|
||||
|
||||
import { Column } from '../../components/ui';
|
||||
|
||||
import ChatList from './components/chat_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
});
|
||||
|
||||
const ChatIndex: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSuggestion = (accountId: string) => {
|
||||
dispatch(launchChat(accountId, history, true));
|
||||
};
|
||||
|
||||
const handleClickChat = (chat: { id: string }) => {
|
||||
history.push(`/chats/${chat.id}`);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(fetchChats());
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<div className='column__switch'>
|
||||
<AudioToggle />
|
||||
</div>
|
||||
|
||||
<AccountSearch
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onSelected={handleSuggestion}
|
||||
/>
|
||||
|
||||
<ChatList
|
||||
onClickChat={handleClickChat}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatIndex;
|
|
@ -26,7 +26,7 @@ const mapDispatchToProps = dispatch => ({
|
|||
},
|
||||
|
||||
onOpenModal: media => {
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0, onClose: console.log }));
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
|
||||
},
|
||||
|
||||
onSubmit(router) {
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 { Spinner } from 'soapbox/components/ui';
|
||||
|
||||
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import DomainContainer from '../../containers/domain_container';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
domains: state.getIn(['domain_lists', 'blocks', 'items']),
|
||||
hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Blocks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
hasMore: PropTypes.bool,
|
||||
domains: ImmutablePropTypes.orderedSet,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchDomainBlocks());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandDomainBlocks());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, domains, hasMore } = this.props;
|
||||
|
||||
if (!domains) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />;
|
||||
|
||||
return (
|
||||
<Column icon='minus-circle' label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='domain_blocks'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{domains.map(domain =>
|
||||
<DomainContainer key={domain} domain={domain} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchDomainBlocks, expandDomainBlocks } from 'soapbox/actions/domain_blocks';
|
||||
import Domain from 'soapbox/components/domain';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Spinner } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandDomainBlocks());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const DomainBlocks: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const domains = useAppSelector((state) => state.domain_lists.getIn(['blocks', 'items'])) as string[];
|
||||
const hasMore = useAppSelector((state) => !!state.domain_lists.getIn(['blocks', 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchDomainBlocks());
|
||||
}, []);
|
||||
|
||||
if (!domains) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />;
|
||||
|
||||
return (
|
||||
<Column icon='minus-circle' label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='domain_blocks'
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{domains.map((domain) =>
|
||||
<Domain key={domain} domain={domain} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainBlocks;
|
|
@ -14,7 +14,7 @@ const { unicodeToFilename } = require('./unicode_to_filename');
|
|||
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
|
||||
|
||||
|
||||
if(data.compressed) {
|
||||
if (data.compressed) {
|
||||
data = emojiMartUncompress(data);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,50 +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 } from 'react-intl';
|
||||
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Permalink from '../../../components/permalink';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class AccountAuthorize extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
onAuthorize: PropTypes.func.isRequired,
|
||||
onReject: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, account, onAuthorize, onReject } = this.props;
|
||||
const content = { __html: account.get('note_emojified') };
|
||||
|
||||
return (
|
||||
<div className='account-authorize__wrapper'>
|
||||
<div className='account-authorize'>
|
||||
<Permalink href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
|
||||
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
|
||||
<div className='account--panel'>
|
||||
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} src={require('@tabler/icons/icons/check.svg')} onClick={onAuthorize} /></div>
|
||||
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} src={require('@tabler/icons/icons/x.svg')} onClick={onReject} /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import DisplayName from 'soapbox/components/display_name';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import Permalink from 'soapbox/components/permalink';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
|
||||
});
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
interface IAccountAuthorize {
|
||||
id: string,
|
||||
}
|
||||
|
||||
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, id));
|
||||
|
||||
const onAuthorize = () => {
|
||||
dispatch(authorizeFollowRequest(id));
|
||||
};
|
||||
|
||||
const onReject = () => {
|
||||
dispatch(rejectFollowRequest(id));
|
||||
};
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const content = { __html: account.note_emojified };
|
||||
|
||||
return (
|
||||
<div className='account-authorize__wrapper'>
|
||||
<div className='account-authorize'>
|
||||
<Permalink href={`/@${account.acct}`} to={`/@${account.acct}`} className='detailed-status__display-name'>
|
||||
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
|
||||
<div className='account--panel'>
|
||||
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} src={require('@tabler/icons/icons/check.svg')} onClick={onAuthorize} /></div>
|
||||
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} src={require('@tabler/icons/icons/x.svg')} onClick={onReject} /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountAuthorize;
|
|
@ -1,27 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
import AccountAuthorize from '../components/account_authorize';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
onAuthorize() {
|
||||
dispatch(authorizeFollowRequest(id));
|
||||
},
|
||||
|
||||
onReject() {
|
||||
dispatch(rejectFollowRequest(id));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
|
|
@ -1,75 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 { Spinner } from 'soapbox/components/ui';
|
||||
|
||||
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import AccountAuthorizeContainer from './containers/account_authorize_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class FollowRequests extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
hasMore: PropTypes.bool,
|
||||
accountIds: ImmutablePropTypes.orderedSet,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowRequests());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowRequests());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, accountIds, hasMore } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column icon='user-plus' labellabel={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='follow_requests'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountAuthorizeContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchFollowRequests, expandFollowRequests } from 'soapbox/actions/accounts';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Spinner } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import AccountAuthorize from './components/account_authorize';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandFollowRequests());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const FollowRequests: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const accountIds = useAppSelector<string[]>((state) => state.user_lists.getIn(['follow_requests', 'items']));
|
||||
const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['follow_requests', 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchFollowRequests());
|
||||
}, []);
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column icon='user-plus' label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='follow_requests'
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountAuthorize key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowRequests;
|
|
@ -1,74 +0,0 @@
|
|||
import { debounce } from 'lodash';
|
||||
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 { Column, Spinner } from 'soapbox/components/ui';
|
||||
|
||||
import { fetchMutes, expandMutes } from '../../actions/mutes';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'mutes', 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Mutes extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
hasMore: PropTypes.bool,
|
||||
accountIds: ImmutablePropTypes.orderedSet,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchMutes());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandMutes());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render() {
|
||||
const { intl, hasMore, accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='mutes'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { debounce } from 'lodash';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchMutes, expandMutes } from 'soapbox/actions/mutes';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Column, Spinner } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandMutes());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const Mutes: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const accountIds = useAppSelector((state) => state.user_lists.getIn(['mutes', 'items']));
|
||||
const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['mutes', 'next']));
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchMutes());
|
||||
}, []);
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='mutes'
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-4'
|
||||
>
|
||||
{accountIds.map((id: string) =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mutes;
|
|
@ -4,6 +4,7 @@ import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps
|
|||
import { connect } from 'react-redux';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
@ -355,9 +356,10 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
|
|||
'😮': messages.reactionOpenMouth,
|
||||
'😢': messages.reactionCry,
|
||||
'😩': messages.reactionWeary,
|
||||
'': messages.favourite,
|
||||
};
|
||||
|
||||
const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite);
|
||||
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite);
|
||||
|
||||
const menu: Menu = [];
|
||||
|
||||
|
@ -573,19 +575,36 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
|
|||
|
||||
{reblogButton}
|
||||
|
||||
<IconButton
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': !meEmojiReact,
|
||||
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
title={meEmojiTitle}
|
||||
src={require('@tabler/icons/icons/heart.svg')}
|
||||
iconClassName={classNames({
|
||||
'fill-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
text={meEmojiTitle}
|
||||
onClick={this.handleLikeButtonClick}
|
||||
/>
|
||||
{features.emojiReacts ? (
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<IconButton
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': !meEmojiReact,
|
||||
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
title={meEmojiTitle}
|
||||
src={require('@tabler/icons/icons/heart.svg')}
|
||||
iconClassName={classNames({
|
||||
'fill-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
text={meEmojiTitle}
|
||||
/>
|
||||
</EmojiButtonWrapper>
|
||||
) : (
|
||||
<IconButton
|
||||
className={classNames({
|
||||
'text-gray-400 hover:text-gray-600': !meEmojiReact,
|
||||
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
title={meEmojiTitle}
|
||||
src={require('@tabler/icons/icons/heart.svg')}
|
||||
iconClassName={classNames({
|
||||
'fill-accent-300': Boolean(meEmojiReact),
|
||||
})}
|
||||
text={meEmojiTitle}
|
||||
onClick={this.handleLikeButtonClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
<IconButton
|
||||
|
|
|
@ -8,7 +8,8 @@ import { withRouter } from 'react-router-dom';
|
|||
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
|
|
|
@ -22,7 +22,7 @@ const isSafeUrl = text => {
|
|||
try {
|
||||
const url = new URL(text);
|
||||
return ['http:', 'https:'].includes(url.protocol);
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -229,8 +229,10 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
|
||||
<WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} />
|
||||
|
||||
// NOTE: we cannot nest routes in a fragment
|
||||
// https://stackoverflow.com/a/68637108
|
||||
{/*
|
||||
NOTE: we cannot nest routes in a fragment
|
||||
https://stackoverflow.com/a/68637108
|
||||
*/}
|
||||
{features.federating && <WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} publicRoute />}
|
||||
{features.federating && <WrappedRoute path='/timeline/fediverse' exact page={HomePage} component={PublicTimeline} content={children} publicRoute />}
|
||||
{features.federating && <WrappedRoute path='/timeline/:instance' exact page={RemoteInstancePage} component={RemoteTimeline} content={children} />}
|
||||
|
|
|
@ -2,7 +2,8 @@ import { Map as ImmutableMap } from 'immutable';
|
|||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { __stub } from '../../../__mocks__/api';
|
||||
import { __stub } from 'soapbox/api';
|
||||
|
||||
import { render, screen } from '../../../jest/test-helpers';
|
||||
import Verification from '../index';
|
||||
|
||||
|
|
|
@ -216,12 +216,12 @@ class Video extends React.PureComponent {
|
|||
handleMouseVolSlide = throttle(e => {
|
||||
const { x } = getPointerPosition(this.volume, e);
|
||||
|
||||
if(!isNaN(x)) {
|
||||
if (!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if(x > 1) {
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if(x < 0) {
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -185,4 +185,10 @@ describe('normalizeInstance()', () => {
|
|||
|
||||
expect(result.version).toEqual('3.5.0-rc1');
|
||||
});
|
||||
|
||||
it('normalizes Pixelfed instance', () => {
|
||||
const instance = require('soapbox/__fixtures__/pixelfed-instance.json');
|
||||
const result = normalizeInstance(instance);
|
||||
expect(result.title).toBe('pixelfed');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { Account, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const ChatRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
id: '',
|
||||
unread: 0,
|
||||
last_message: '' as string || null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
export const normalizeChat = (chat: Record<string, any>) => {
|
||||
return ChatRecord(
|
||||
ImmutableMap(fromJS(chat)),
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
List as ImmutableList,
|
||||
Map as ImmutableMap,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
||||
|
||||
export const ChatMessageRecord = ImmutableRecord({
|
||||
account_id: '',
|
||||
attachment: null as Attachment | null,
|
||||
card: null as Card | null,
|
||||
chat_id: '',
|
||||
content: '',
|
||||
created_at: new Date(),
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
id: '',
|
||||
unread: false,
|
||||
|
||||
deleting: false,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||
return ChatMessageRecord(
|
||||
ImmutableMap(fromJS(chatMessage)),
|
||||
);
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
export { AccountRecord, FieldRecord, normalizeAccount } from './account';
|
||||
export { AttachmentRecord, normalizeAttachment } from './attachment';
|
||||
export { CardRecord, normalizeCard } from './card';
|
||||
export { ChatRecord, normalizeChat } from './chat';
|
||||
export { ChatMessageRecord, normalizeChatMessage } from './chat_message';
|
||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||
export { InstanceRecord, normalizeInstance } from './instance';
|
||||
export { MentionRecord, normalizeMention } from './mention';
|
||||
|
|
|
@ -44,7 +44,7 @@ const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)));
|
|||
const validUser = user => {
|
||||
try {
|
||||
return validId(user.get('id')) && validId(user.get('access_token'));
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import {
|
||||
CHATS_FETCH_SUCCESS,
|
||||
|
@ -10,41 +11,46 @@ import {
|
|||
} from 'soapbox/actions/chats';
|
||||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
const initialState = ImmutableMap();
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const idComparator = (a, b) => {
|
||||
type State = ImmutableMap<string, ImmutableOrderedSet<string>>;
|
||||
|
||||
const initialState: State = ImmutableMap();
|
||||
|
||||
const idComparator = (a: string, b: string) => {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const updateList = (state, chatId, messageIds) => {
|
||||
const updateList = (state: State, chatId: string, messageIds: string[]) => {
|
||||
const ids = state.get(chatId, ImmutableOrderedSet());
|
||||
const newIds = ids.union(messageIds).sort(idComparator);
|
||||
const newIds = (ids.union(messageIds) as ImmutableOrderedSet<string>).sort(idComparator);
|
||||
return state.set(chatId, newIds);
|
||||
};
|
||||
|
||||
const importMessage = (state, chatMessage) => {
|
||||
const importMessage = (state: State, chatMessage: APIEntity) => {
|
||||
return updateList(state, chatMessage.chat_id, [chatMessage.id]);
|
||||
};
|
||||
|
||||
const importMessages = (state, chatMessages) => (
|
||||
const importMessages = (state: State, chatMessages: APIEntities) => (
|
||||
state.withMutations(map =>
|
||||
chatMessages.forEach(chatMessage =>
|
||||
importMessage(map, chatMessage)))
|
||||
);
|
||||
|
||||
const importLastMessages = (state, chats) =>
|
||||
const importLastMessages = (state: State, chats: APIEntities) =>
|
||||
state.withMutations(mutable =>
|
||||
chats.forEach(chat => {
|
||||
if (chat.last_message) importMessage(mutable, chat.last_message);
|
||||
}));
|
||||
|
||||
const replaceMessage = (state, chatId, oldId, newId) => {
|
||||
return state.update(chatId, chat => chat.delete(oldId).add(newId).sort(idComparator));
|
||||
const replaceMessage = (state: State, chatId: string, oldId: string, newId: string) => {
|
||||
return state.update(chatId, chat => chat!.delete(oldId).add(newId).sort(idComparator));
|
||||
};
|
||||
|
||||
export default function chatMessageLists(state = initialState, action) {
|
||||
export default function chatMessageLists(state = initialState, action: AnyAction) {
|
||||
switch(action.type) {
|
||||
case CHAT_MESSAGE_SEND_REQUEST:
|
||||
return updateList(state, action.chatId, [action.uuid]);
|
||||
|
@ -58,11 +64,11 @@ export default function chatMessageLists(state = initialState, action) {
|
|||
else
|
||||
return state;
|
||||
case CHAT_MESSAGES_FETCH_SUCCESS:
|
||||
return updateList(state, action.chatId, action.chatMessages.map(chat => chat.id));
|
||||
return updateList(state, action.chatId, action.chatMessages.map((chat: APIEntity) => chat.id));
|
||||
case CHAT_MESSAGE_SEND_SUCCESS:
|
||||
return replaceMessage(state, action.chatId, action.uuid, action.chatMessage.id);
|
||||
case CHAT_MESSAGE_DELETE_SUCCESS:
|
||||
return state.update(action.chatId, chat => chat.delete(action.messageId));
|
||||
return state.update(action.chatId, chat => chat!.delete(action.messageId));
|
||||
default:
|
||||
return state;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import {
|
||||
CHATS_FETCH_SUCCESS,
|
||||
|
@ -10,25 +11,32 @@ import {
|
|||
CHAT_MESSAGE_DELETE_SUCCESS,
|
||||
} from 'soapbox/actions/chats';
|
||||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||
|
||||
const initialState = ImmutableMap();
|
||||
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
const importMessage = (state, message) => {
|
||||
return state.set(message.get('id'), message);
|
||||
type State = ImmutableMap<string, ChatMessageRecord>;
|
||||
|
||||
const importMessage = (state: State, message: APIEntity) => {
|
||||
return state.set(message.id, normalizeChatMessage(message));
|
||||
};
|
||||
|
||||
const importMessages = (state, messages) =>
|
||||
const importMessages = (state: State, messages: APIEntities) =>
|
||||
state.withMutations(mutable =>
|
||||
messages.forEach(message => importMessage(mutable, message)));
|
||||
|
||||
const importLastMessages = (state, chats) =>
|
||||
const importLastMessages = (state: State, chats: APIEntities) =>
|
||||
state.withMutations(mutable =>
|
||||
chats.forEach(chat => {
|
||||
if (chat.get('last_message'))
|
||||
importMessage(mutable, chat.get('last_message'));
|
||||
if (chat.last_message)
|
||||
importMessage(mutable, chat.last_message);
|
||||
}));
|
||||
|
||||
export default function chatMessages(state = initialState, action) {
|
||||
const initialState: State = ImmutableMap();
|
||||
|
||||
export default function chatMessages(state = initialState, action: AnyAction) {
|
||||
switch(action.type) {
|
||||
case CHAT_MESSAGE_SEND_REQUEST:
|
||||
return importMessage(state, fromJS({
|
||||
|
@ -41,16 +49,16 @@ export default function chatMessages(state = initialState, action) {
|
|||
}));
|
||||
case CHATS_FETCH_SUCCESS:
|
||||
case CHATS_EXPAND_SUCCESS:
|
||||
return importLastMessages(state, fromJS(action.chats));
|
||||
return importLastMessages(state, action.chats);
|
||||
case CHAT_MESSAGES_FETCH_SUCCESS:
|
||||
return importMessages(state, fromJS(action.chatMessages));
|
||||
return importMessages(state, action.chatMessages);
|
||||
case CHAT_MESSAGE_SEND_SUCCESS:
|
||||
return importMessage(state, fromJS(action.chatMessage)).delete(action.uuid);
|
||||
case STREAMING_CHAT_UPDATE:
|
||||
return importLastMessages(state, fromJS([action.chat]));
|
||||
return importLastMessages(state, [action.chat]);
|
||||
case CHAT_MESSAGE_DELETE_REQUEST:
|
||||
return state.update(action.messageId, chatMessage =>
|
||||
chatMessage.set('pending', true).set('deleting', true));
|
||||
chatMessage!.set('pending', true).set('deleting', true));
|
||||
case CHAT_MESSAGE_DELETE_SUCCESS:
|
||||
return state.delete(action.messageId);
|
||||
default:
|
|
@ -1,58 +0,0 @@
|
|||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
CHATS_FETCH_SUCCESS,
|
||||
CHATS_FETCH_REQUEST,
|
||||
CHATS_EXPAND_SUCCESS,
|
||||
CHATS_EXPAND_REQUEST,
|
||||
CHAT_FETCH_SUCCESS,
|
||||
CHAT_READ_SUCCESS,
|
||||
CHAT_READ_REQUEST,
|
||||
} from 'soapbox/actions/chats';
|
||||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
|
||||
const normalizeChat = (chat, normalOldChat) => {
|
||||
const normalChat = { ...chat };
|
||||
const { account, last_message: lastMessage } = chat;
|
||||
|
||||
if (account) normalChat.account = account.id;
|
||||
if (lastMessage) normalChat.last_message = lastMessage.id;
|
||||
|
||||
return normalChat;
|
||||
};
|
||||
|
||||
const importChat = (state, chat) => state.setIn(['items', chat.id], fromJS(normalizeChat(chat)));
|
||||
|
||||
const importChats = (state, chats, next) =>
|
||||
state.withMutations(mutable => {
|
||||
if (next !== undefined) mutable.set('next', next);
|
||||
chats.forEach(chat => importChat(mutable, chat));
|
||||
mutable.set('loading', false);
|
||||
});
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
next: null,
|
||||
isLoading: false,
|
||||
items: ImmutableMap({}),
|
||||
});
|
||||
|
||||
export default function chats(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case CHATS_FETCH_REQUEST:
|
||||
case CHATS_EXPAND_REQUEST:
|
||||
return state.set('loading', true);
|
||||
case CHATS_FETCH_SUCCESS:
|
||||
case CHATS_EXPAND_SUCCESS:
|
||||
return importChats(state, action.chats, action.next);
|
||||
case STREAMING_CHAT_UPDATE:
|
||||
return importChats(state, [action.chat]);
|
||||
case CHAT_FETCH_SUCCESS:
|
||||
return importChats(state, [action.chat]);
|
||||
case CHAT_READ_REQUEST:
|
||||
return state.setIn([action.chatId, 'unread'], 0);
|
||||
case CHAT_READ_SUCCESS:
|
||||
return importChats(state, [action.chat]);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
CHATS_FETCH_SUCCESS,
|
||||
CHATS_FETCH_REQUEST,
|
||||
CHATS_EXPAND_SUCCESS,
|
||||
CHATS_EXPAND_REQUEST,
|
||||
CHAT_FETCH_SUCCESS,
|
||||
CHAT_READ_SUCCESS,
|
||||
CHAT_READ_REQUEST,
|
||||
} from 'soapbox/actions/chats';
|
||||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||
import { normalizeChat } from 'soapbox/normalizers';
|
||||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
type ChatRecord = ReturnType<typeof normalizeChat>;
|
||||
type APIEntity = Record<string, any>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
|
||||
export interface ReducerChat extends ChatRecord {
|
||||
account: string | null,
|
||||
last_message: string | null,
|
||||
}
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
next: null as string | null,
|
||||
isLoading: false,
|
||||
items: ImmutableMap<ReducerChat>({}),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
const minifyChat = (chat: ChatRecord): ReducerChat => {
|
||||
return chat.mergeWith((o, n) => n || o, {
|
||||
account: normalizeId(chat.getIn(['account', 'id'])),
|
||||
last_message: normalizeId(chat.getIn(['last_message', 'id'])),
|
||||
}) as ReducerChat;
|
||||
};
|
||||
|
||||
const fixChat = (chat: APIEntity): ReducerChat => {
|
||||
return normalizeChat(chat).withMutations(chat => {
|
||||
minifyChat(chat);
|
||||
}) as ReducerChat;
|
||||
};
|
||||
|
||||
const importChat = (state: State, chat: APIEntity) => state.setIn(['items', chat.id], fixChat(chat));
|
||||
|
||||
const importChats = (state: State, chats: APIEntities, next?: string) =>
|
||||
state.withMutations(mutable => {
|
||||
if (next !== undefined) mutable.set('next', next);
|
||||
chats.forEach(chat => importChat(mutable, chat));
|
||||
mutable.set('isLoading', false);
|
||||
});
|
||||
|
||||
export default function chats(state: State = ReducerRecord(), action: AnyAction): State {
|
||||
switch(action.type) {
|
||||
case CHATS_FETCH_REQUEST:
|
||||
case CHATS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case CHATS_FETCH_SUCCESS:
|
||||
case CHATS_EXPAND_SUCCESS:
|
||||
return importChats(state, action.chats, action.next);
|
||||
case STREAMING_CHAT_UPDATE:
|
||||
return importChats(state, [action.chat]);
|
||||
case CHAT_FETCH_SUCCESS:
|
||||
return importChats(state, [action.chat]);
|
||||
case CHAT_READ_REQUEST:
|
||||
return state.setIn([action.chatId, 'unread'], 0);
|
||||
case CHAT_READ_SUCCESS:
|
||||
return importChats(state, [action.chat]);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -58,7 +58,7 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
|
|||
list = list.concat(items);
|
||||
|
||||
return list.sortBy(x => x.get('last_status'), (a, b) => {
|
||||
if(a === null || b === null) {
|
||||
if (a === null || b === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth';
|
|||
import ConfigDB from 'soapbox/utils/config_db';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import type { ReducerChat } from 'soapbox/reducers/chats';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { Notification } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -241,16 +242,18 @@ type APIChat = { id: string, last_message: string };
|
|||
export const makeGetChat = () => {
|
||||
return createSelector(
|
||||
[
|
||||
(state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]),
|
||||
(state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]) as ReducerChat,
|
||||
(state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])),
|
||||
(state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message),
|
||||
],
|
||||
|
||||
(chat, account, lastMessage: string) => {
|
||||
if (!chat) return null;
|
||||
(chat, account, lastMessage) => {
|
||||
if (!chat || !account) return null;
|
||||
|
||||
return chat.withMutations((map: ImmutableMap<string, any>) => {
|
||||
return chat.withMutations((map) => {
|
||||
// @ts-ignore
|
||||
map.set('account', account);
|
||||
// @ts-ignore
|
||||
map.set('last_message', lastMessage);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -93,7 +93,7 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co
|
|||
if (!e.data) return;
|
||||
try {
|
||||
received(JSON.parse(e.data));
|
||||
} catch(error) {
|
||||
} catch (error) {
|
||||
console.error(e);
|
||||
console.error(`Could not parse the above streaming event.\n${error}`);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import {
|
|||
AccountRecord,
|
||||
AttachmentRecord,
|
||||
CardRecord,
|
||||
ChatRecord,
|
||||
ChatMessageRecord,
|
||||
EmojiRecord,
|
||||
FieldRecord,
|
||||
InstanceRecord,
|
||||
|
@ -16,6 +18,8 @@ import type { Record as ImmutableRecord } from 'immutable';
|
|||
|
||||
type Attachment = ReturnType<typeof AttachmentRecord>;
|
||||
type Card = ReturnType<typeof CardRecord>;
|
||||
type Chat = ReturnType<typeof ChatRecord>;
|
||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Instance = ReturnType<typeof InstanceRecord>;
|
||||
|
@ -44,6 +48,8 @@ export {
|
|||
Account,
|
||||
Attachment,
|
||||
Card,
|
||||
Chat,
|
||||
ChatMessage,
|
||||
Emoji,
|
||||
Field,
|
||||
Instance,
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
removeVS16s,
|
||||
toCodePoints,
|
||||
} from '../emoji';
|
||||
|
||||
const ASCII_HEART = '❤'; // '\u2764\uFE0F'
|
||||
const RED_HEART_RGI = '❤️'; // '\u2764'
|
||||
const JOY = '😂';
|
||||
|
||||
describe('removeVS16s()', () => {
|
||||
it('removes Variation Selector-16 characters from emoji', () => {
|
||||
// Sanity check
|
||||
expect(ASCII_HEART).not.toBe(RED_HEART_RGI);
|
||||
|
||||
// It normalizes an emoji with VS16s
|
||||
expect(removeVS16s(RED_HEART_RGI)).toBe(ASCII_HEART);
|
||||
|
||||
// Leaves a regular emoji alone
|
||||
expect(removeVS16s(JOY)).toBe(JOY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCodePoints()', () => {
|
||||
it('converts a plain emoji', () => {
|
||||
expect(toCodePoints('😂')).toEqual(['1f602']);
|
||||
});
|
||||
|
||||
it('converts a VS16 emoji', () => {
|
||||
expect(toCodePoints(RED_HEART_RGI)).toEqual(['2764', 'fe0f']);
|
||||
});
|
||||
|
||||
it('converts an ASCII character', () => {
|
||||
expect(toCodePoints(ASCII_HEART)).toEqual(['2764']);
|
||||
});
|
||||
|
||||
it('converts a sequence emoji', () => {
|
||||
expect(toCodePoints('🇺🇸')).toEqual(['1f1fa', '1f1f8']);
|
||||
});
|
||||
});
|
|
@ -23,6 +23,15 @@ describe('parseVersion', () => {
|
|||
compatVersion: '3.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('with a Pixelfed version string', () => {
|
||||
const version = '2.7.2 (compatible; Pixelfed 0.11.2)';
|
||||
expect(parseVersion(version)).toEqual({
|
||||
software: 'Pixelfed',
|
||||
version: '0.11.2',
|
||||
compatVersion: '2.7.2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatures', () => {
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
export const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined';
|
||||
|
||||
export const isURL = url => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseBaseURL = url => {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoggedInAccount = state => {
|
||||
const me = state.get('me');
|
||||
return state.getIn(['accounts', me]);
|
||||
};
|
||||
|
||||
export const isLoggedIn = getState => {
|
||||
return validId(getState().get('me'));
|
||||
};
|
||||
|
||||
export const getAppToken = state => state.getIn(['auth', 'app', 'access_token']);
|
||||
|
||||
export const getUserToken = (state, accountId) => {
|
||||
const accountUrl = state.getIn(['accounts', accountId, 'url']);
|
||||
return state.getIn(['auth', 'users', accountUrl, 'access_token']);
|
||||
};
|
||||
|
||||
export const getAccessToken = state => {
|
||||
const me = state.get('me');
|
||||
return getUserToken(state, me);
|
||||
};
|
||||
|
||||
export const getAuthUserId = state => {
|
||||
const me = state.getIn(['auth', 'me']);
|
||||
|
||||
return ImmutableList([
|
||||
state.getIn(['auth', 'users', me, 'id']),
|
||||
me,
|
||||
]).find(validId);
|
||||
};
|
||||
|
||||
export const getAuthUserUrl = state => {
|
||||
const me = state.getIn(['auth', 'me']);
|
||||
|
||||
return ImmutableList([
|
||||
state.getIn(['auth', 'users', me, 'url']),
|
||||
me,
|
||||
]).find(isURL);
|
||||
};
|
||||
|
||||
/** Get the VAPID public key. */
|
||||
export const getVapidKey = state => {
|
||||
return state.getIn(['auth', 'app', 'vapid_key']) || state.getIn(['instance', 'pleroma', 'vapid_public_key']);
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
export const validId = (id: any) => typeof id === 'string' && id !== 'null' && id !== 'undefined';
|
||||
|
||||
export const isURL = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseBaseURL = (url: any) => {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoggedInAccount = (state: RootState) => {
|
||||
const me = state.me;
|
||||
return state.accounts.get(me);
|
||||
};
|
||||
|
||||
export const isLoggedIn = (getState: () => RootState) => {
|
||||
return validId(getState().me);
|
||||
};
|
||||
|
||||
export const getAppToken = (state: RootState) => state.auth.getIn(['app', 'access_token']);
|
||||
|
||||
export const getUserToken = (state: RootState, accountId?: string | false | null) => {
|
||||
const accountUrl = state.accounts.getIn([accountId, 'url']);
|
||||
return state.auth.getIn(['users', accountUrl, 'access_token']);
|
||||
};
|
||||
|
||||
export const getAccessToken = (state: RootState) => {
|
||||
const me = state.me;
|
||||
return getUserToken(state, me);
|
||||
};
|
||||
|
||||
export const getAuthUserId = (state: RootState) => {
|
||||
const me = state.auth.get('me');
|
||||
|
||||
return ImmutableList([
|
||||
state.auth.getIn(['users', me, 'id']),
|
||||
me,
|
||||
]).find(validId);
|
||||
};
|
||||
|
||||
export const getAuthUserUrl = (state: RootState) => {
|
||||
const me = state.auth.get('me');
|
||||
|
||||
return ImmutableList([
|
||||
state.auth.getIn(['users', me, 'url']),
|
||||
me,
|
||||
]).find(isURL);
|
||||
};
|
||||
|
||||
/** Get the VAPID public key. */
|
||||
export const getVapidKey = (state: RootState) => {
|
||||
return state.auth.getIn(['app', 'vapid_key']) || state.instance.getIn(['pleroma', 'vapid_public_key']);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
export const decode = base64 => {
|
||||
export const decode = (base64: string) => {
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// Taken from twemoji-parser
|
||||
// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js
|
||||
|
||||
/** Remove Variation Selector-16 characters from emoji */
|
||||
// https://emojipedia.org/variation-selector-16/
|
||||
const removeVS16s = (rawEmoji: string): string => {
|
||||
const vs16RegExp = /\uFE0F/g;
|
||||
const zeroWidthJoiner = String.fromCharCode(0x200d);
|
||||
return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji;
|
||||
};
|
||||
|
||||
/** Convert emoji into an array of Unicode codepoints */
|
||||
const toCodePoints = (unicodeSurrogates: string): string[] => {
|
||||
const points = [];
|
||||
let char = 0;
|
||||
let previous = 0;
|
||||
let i = 0;
|
||||
while (i < unicodeSurrogates.length) {
|
||||
char = unicodeSurrogates.charCodeAt(i++);
|
||||
if (previous) {
|
||||
points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16));
|
||||
previous = 0;
|
||||
} else if (char > 0xd800 && char <= 0xdbff) {
|
||||
previous = char;
|
||||
} else {
|
||||
points.push(char.toString(16));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export {
|
||||
removeVS16s,
|
||||
toCodePoints,
|
||||
};
|
|
@ -19,6 +19,7 @@ export const MASTODON = 'Mastodon';
|
|||
export const PLEROMA = 'Pleroma';
|
||||
export const MITRA = 'Mitra';
|
||||
export const TRUTHSOCIAL = 'TruthSocial';
|
||||
export const PIXELFED = 'Pixelfed';
|
||||
|
||||
const getInstanceFeatures = (instance: Instance) => {
|
||||
const v = parseVersion(instance.version);
|
||||
|
@ -41,6 +42,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
bookmarks: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '3.1.0'),
|
||||
v.software === PLEROMA && gte(v.version, '0.9.9'),
|
||||
v.software === PIXELFED,
|
||||
]),
|
||||
lists: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '2.1.0'),
|
||||
|
@ -73,6 +75,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
conversations: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '2.6.0'),
|
||||
v.software === PLEROMA && gte(v.version, '0.9.9'),
|
||||
v.software === PIXELFED,
|
||||
]),
|
||||
emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'),
|
||||
emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'),
|
||||
|
@ -83,7 +86,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
chats: v.software === PLEROMA && gte(v.version, '2.1.0'),
|
||||
chatsV2: v.software === PLEROMA && gte(v.version, '2.3.0'),
|
||||
scopes: v.software === PLEROMA ? 'read write follow push admin' : 'read write follow push',
|
||||
federating: federation.get('enabled', true), // Assume true unless explicitly false
|
||||
federating: federation.get('enabled', true) === true, // Assume true unless explicitly false
|
||||
richText: v.software === PLEROMA,
|
||||
securityAPI: any([
|
||||
v.software === PLEROMA,
|
||||
|
|
|
@ -15,7 +15,7 @@ export const addGreentext = html => {
|
|||
} else {
|
||||
return string;
|
||||
}
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
return string;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
export const getHost = instance => {
|
||||
try {
|
||||
return new URL(instance.get('uri')).host;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${instance.get('uri')}`).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,17 +1,17 @@
|
|||
export const minimumAspectRatio = 9 / 16; // Portrait phone
|
||||
export const maximumAspectRatio = 10; // Generous min-height
|
||||
|
||||
export const isPanoramic = ar => {
|
||||
export const isPanoramic = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return ar >= maximumAspectRatio;
|
||||
};
|
||||
|
||||
export const isPortrait = ar => {
|
||||
export const isPortrait = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return ar <= minimumAspectRatio;
|
||||
};
|
||||
|
||||
export const isNonConformingRatio = ar => {
|
||||
export const isNonConformingRatio = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return !isPanoramic(ar) && !isPortrait(ar);
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
import { isIntegerId } from 'soapbox/utils/numbers';
|
||||
|
||||
export const getFirstExternalLink = status => {
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const getFirstExternalLink = (status: StatusEntity) => {
|
||||
try {
|
||||
// Pulled from Pleroma's media parser
|
||||
const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])';
|
||||
|
@ -12,11 +14,11 @@ export const getFirstExternalLink = status => {
|
|||
}
|
||||
};
|
||||
|
||||
export const shouldHaveCard = status => {
|
||||
export const shouldHaveCard = (status: StatusEntity) => {
|
||||
return Boolean(getFirstExternalLink(status));
|
||||
};
|
||||
|
||||
// https://gitlab.com/soapbox-pub/soapbox-fe/-/merge_requests/1087
|
||||
export const hasIntegerMediaIds = status => {
|
||||
export const hasIntegerMediaIds = (status: StatusEntity) => {
|
||||
return status.media_attachments.some(({ id }) => isIntegerId(id));
|
||||
};
|
|
@ -1,6 +1,8 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
export const shouldFilter = (status, columnSettings) => {
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
export const shouldFilter = (status: StatusEntity, columnSettings: any) => {
|
||||
const shows = ImmutableMap({
|
||||
reblog: status.get('reblog') !== null,
|
||||
reply: status.get('in_reply_to_id') !== null,
|
|
@ -38,6 +38,34 @@ For example:
|
|||
|
||||
See `app/soapbox/utils/features.js` for the full list of features.
|
||||
|
||||
### Embedded app (`custom/app.json`)
|
||||
|
||||
By default, Soapbox will create a new OAuth app every time a user tries to register or log in.
|
||||
This is usually the desired behavior, as it works "out of the box" without any additional configuration, and it is resistant to tampering and subtle client bugs.
|
||||
However, some larger servers may wish to skip this step for performance reasons.
|
||||
|
||||
If an app is supplied in `custom/app.json`, it will be used for authorization.
|
||||
The full app entity must be provided, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"client_id": "cf5yI6ffXH1UcDkEApEIrtHpwCi5Tv9xmju8IKdMAkE",
|
||||
"client_secret": "vHmSDpm6BJGUvR4_qWzmqWjfHcSYlZumxpFfohRwNNQ",
|
||||
"id": "7132",
|
||||
"name": "Soapbox FE",
|
||||
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
||||
"website": "https://soapbox.pub/",
|
||||
"vapid_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4"
|
||||
}
|
||||
```
|
||||
|
||||
It is crucial that the app has the expected scopes.
|
||||
You can obtain one with the following curl command (replace `MY_DOMAIN`):
|
||||
|
||||
```sh
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"client_name": "Soapbox FE", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow push admin", "website": "https://soapbox.pub/"}' "https://MY_DOMAIN.com/api/v1/apps"
|
||||
```
|
||||
|
||||
### Custom files (`custom/instance/*`)
|
||||
|
||||
You can place arbitrary files of any type in the `custom/instance/` directory.
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
"@types/jest": "^27.4.1",
|
||||
"@types/lodash": "^4.14.180",
|
||||
"@types/qrcode.react": "^1.0.2",
|
||||
"@types/react-datepicker": "^4.4.0",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-motion": "^0.0.32",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
|
@ -145,7 +146,7 @@
|
|||
"qrcode.react": "^1.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-color": "^2.18.1",
|
||||
"react-datepicker": "^4.6.0",
|
||||
"react-datepicker": "^4.7.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-helmet": "^6.0.0",
|
||||
"react-hotkeys": "^1.1.4",
|
||||
|
|
|
@ -75,7 +75,7 @@ module.exports = {
|
|||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
}),
|
||||
new ForkTsCheckerWebpackPlugin(),
|
||||
new ForkTsCheckerWebpackPlugin({ typescript: { memoryLimit: 8192 } }),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'packs/css/[name]-[contenthash:8].css',
|
||||
chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css',
|
||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -2171,6 +2171,16 @@
|
|||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-datepicker@^4.4.0":
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.4.0.tgz#0072e18536ad305fd57786f9b6f9e499eed2b475"
|
||||
integrity sha512-wzmevaO51rLFwSZd5HSqBU0aAvZlRRkj6QhHqj0jfRDSKnN3y5IKXyhgxPS8R0LOWOtjdpirI1DBryjnIp/7gA==
|
||||
dependencies:
|
||||
"@popperjs/core" "^2.9.2"
|
||||
"@types/react" "*"
|
||||
date-fns "^2.0.1"
|
||||
react-popper "^2.2.5"
|
||||
|
||||
"@types/react-dom@*":
|
||||
version "17.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f"
|
||||
|
@ -4006,7 +4016,7 @@ data-urls@^2.0.0:
|
|||
whatwg-mimetype "^2.3.0"
|
||||
whatwg-url "^8.0.0"
|
||||
|
||||
date-fns@^2.24.0:
|
||||
date-fns@^2.0.1, date-fns@^2.24.0:
|
||||
version "2.28.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2"
|
||||
integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==
|
||||
|
@ -8662,7 +8672,7 @@ react-color@^2.18.1:
|
|||
reactcss "^1.2.0"
|
||||
tinycolor2 "^1.4.1"
|
||||
|
||||
react-datepicker@^4.6.0:
|
||||
react-datepicker@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.7.0.tgz#75e03b0a6718b97b84287933307faf2ed5f03cf4"
|
||||
integrity sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw==
|
||||
|
|
Loading…
Reference in New Issue