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