Merge remote-tracking branch 'origin/next' into next-theme-picker

This commit is contained in:
Alex Gleason 2022-04-12 19:02:35 -05:00
commit 3e988cb3a3
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
92 changed files with 1466 additions and 1338 deletions

View File

@ -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',

View File

@ -1,4 +1,4 @@
image: node:14
image: node:16
variables:
NODE_ENV: test

View File

@ -1 +1 @@
nodejs 14.17.6
nodejs 16.14.2

View File

@ -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"
}
]
}

View File

@ -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));
};

View File

@ -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);
});
});

View File

@ -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') {

View File

@ -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;
}
};

View File

@ -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,

View File

@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
}).catch(console.error);
}).catch(console.error);
}
} catch(e) {
} catch (e) {
console.warn(e);
}

View File

@ -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) {

View File

@ -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;
}
};

View File

@ -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;
}

View File

@ -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(

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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)}

View File

@ -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 });
}
}

View File

@ -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,

View File

@ -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}

View File

@ -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,
}

View File

@ -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>
);

View File

@ -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));

View File

@ -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`;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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;
});

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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) {

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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,6 +575,22 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
{reblogButton}
{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,
@ -586,6 +604,7 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
text={meEmojiTitle}
onClick={this.handleLikeButtonClick}
/>
)}
{canShare && (
<IconButton

View File

@ -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' },

View File

@ -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;
}
};

View File

@ -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} />}

View File

@ -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';

View File

@ -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;
}

View File

@ -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');
});
});

View File

@ -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)),
);
};

View File

@ -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)),
);
};

View File

@ -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';

View File

@ -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;
}
};

View File

@ -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;
}

View File

@ -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:

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
});
},

View File

@ -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}`);
}

View File

@ -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,

View File

@ -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']);
});
});

View File

@ -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', () => {

View File

@ -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']);
};

66
app/soapbox/utils/auth.ts Normal file
View File

@ -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']);
};

View File

@ -1,4 +1,4 @@
export const decode = base64 => {
export const decode = (base64: string) => {
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);

View File

@ -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,
};

View File

@ -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,

View File

@ -15,7 +15,7 @@ export const addGreentext = html => {
} else {
return string;
}
} catch(e) {
} catch (e) {
return string;
}
});

View File

@ -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;
}
}
};

View File

@ -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);
};

View File

@ -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));
};

View File

@ -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,

View File

@ -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.

View File

@ -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",

View File

@ -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',

View File

@ -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==