diff --git a/app/application.js b/app/application.js index a6d4ed199..96cedfeef 100644 --- a/app/application.js +++ b/app/application.js @@ -2,6 +2,9 @@ import loadPolyfills from './soapbox/load_polyfills'; require.context('./images/', true); +// Load stylesheet +require('./styles/application.scss'); + loadPolyfills().then(() => { require('./soapbox/main').default(); }).catch(e => { diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index e215e5256..9fd4a478e 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -471,8 +471,6 @@ export function unsubscribeAccountFail(error) { export function fetchFollowers(id) { return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - dispatch(fetchFollowersRequest(id)); api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { @@ -561,8 +559,6 @@ export function expandFollowersFail(id, error) { export function fetchFollowing(id) { return (dispatch, getState) => { - if (!isLoggedIn(getState)) return; - dispatch(fetchFollowingRequest(id)); api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { diff --git a/app/soapbox/actions/interactions.js b/app/soapbox/actions/interactions.js index aaa6b6143..1e2d729f1 100644 --- a/app/soapbox/actions/interactions.js +++ b/app/soapbox/actions/interactions.js @@ -28,6 +28,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +export const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; +export const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; +export const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; + export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_SUCCESS = 'PIN_SUCCESS'; export const PIN_FAIL = 'PIN_FAIL'; @@ -359,6 +363,41 @@ export function fetchFavouritesFail(id, error) { }; } +export function fetchReactions(id) { + return (dispatch, getState) => { + dispatch(fetchReactionsRequest(id)); + + api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then(response => { + dispatch(importFetchedAccounts(response.data.map(({ accounts }) => accounts).flat())); + dispatch(fetchReactionsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReactionsFail(id, error)); + }); + }; +} + +export function fetchReactionsRequest(id) { + return { + type: REACTIONS_FETCH_REQUEST, + id, + }; +} + +export function fetchReactionsSuccess(id, reactions) { + return { + type: REACTIONS_FETCH_SUCCESS, + id, + reactions, + }; +} + +export function fetchReactionsFail(id, error) { + return { + type: REACTIONS_FETCH_FAIL, + error, + }; +} + export function pin(status) { return (dispatch, getState) => { if (!isLoggedIn(getState)) return; diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index d2abc83a6..64fbe1891 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -50,6 +50,7 @@ export const makeDefaultConfig = features => { limit: 1, }), aboutPages: ImmutableMap(), + authenticatedProfile: true, }); }; diff --git a/app/soapbox/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap index 1c3727848..6bbb1eb74 100644 --- a/app/soapbox/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap @@ -20,7 +20,11 @@ exports[` renders native emoji 1`] = ` ๐Ÿ’™ :foobar: diff --git a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap index 06d7764ec..d009a5551 100644 --- a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap @@ -15,7 +15,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"๐Ÿ‘\\"", + "__html": "\\"๐Ÿ‘\\"", } } onClick={[Function]} @@ -26,7 +26,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"โค\\"", + "__html": "\\"โค\\"", } } onClick={[Function]} @@ -37,7 +37,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"๐Ÿ˜†\\"", + "__html": "\\"๐Ÿ˜†\\"", } } onClick={[Function]} @@ -48,7 +48,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"๐Ÿ˜ฎ\\"", + "__html": "\\"๐Ÿ˜ฎ\\"", } } onClick={[Function]} @@ -59,7 +59,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"๐Ÿ˜ข\\"", + "__html": "\\"๐Ÿ˜ข\\"", } } onClick={[Function]} @@ -70,7 +70,7 @@ exports[` renders correctly 1`] = ` className="emoji-react-selector__emoji" dangerouslySetInnerHTML={ Object { - "__html": "\\"๐Ÿ˜ฉ\\"", + "__html": "\\"๐Ÿ˜ฉ\\"", } } onClick={[Function]} diff --git a/app/soapbox/components/account.js b/app/soapbox/components/account.js index c23c0420e..d4b616d5d 100644 --- a/app/soapbox/components/account.js +++ b/app/soapbox/components/account.js @@ -12,6 +12,7 @@ import RelativeTimestamp from './relative_timestamp'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import classNames from 'classnames'; +import emojify from 'soapbox/features/emoji/emoji'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -46,6 +47,7 @@ class Account extends ImmutablePureComponent { onActionClick: PropTypes.func, withDate: PropTypes.bool, withRelationship: PropTypes.bool, + reaction: PropTypes.string, }; static defaultProps = { @@ -78,7 +80,7 @@ class Account extends ImmutablePureComponent { } render() { - const { account, intl, hidden, onActionClick, actionIcon, actionTitle, me, withDate, withRelationship } = this.props; + const { account, intl, hidden, onActionClick, actionIcon, actionTitle, me, withDate, withRelationship, reaction } = this.props; if (!account) { return
; @@ -95,6 +97,7 @@ class Account extends ImmutablePureComponent { let buttons; let followedBy; + let emoji; if (onActionClick && actionIcon) { buttons = ; @@ -128,6 +131,15 @@ class Account extends ImmutablePureComponent { } } + if (reaction) { + emoji = ( + + ); + } + const createdAt = account.get('created_at'); const joinedAt = createdAt ? ( @@ -141,7 +153,10 @@ class Account extends ImmutablePureComponent {
-
+
+ {emoji} + +
diff --git a/app/soapbox/components/autosuggest_emoji.js b/app/soapbox/components/autosuggest_emoji.js index 6311061b0..da2df72a3 100644 --- a/app/soapbox/components/autosuggest_emoji.js +++ b/app/soapbox/components/autosuggest_emoji.js @@ -1,8 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; -import { join } from 'path'; -import { FE_SUBDIRECTORY } from 'soapbox/build_config'; export default class AutosuggestEmoji extends React.PureComponent { @@ -23,7 +21,7 @@ export default class AutosuggestEmoji extends React.PureComponent { return null; } - url = join(FE_SUBDIRECTORY, 'emoji', `${mapping.filename}.svg`); + url = require(`twemoji/assets/svg/${mapping.filename}.svg`); } return ( diff --git a/app/soapbox/components/column_back_button.js b/app/soapbox/components/column_back_button.js index 1ce0d8f1f..54b700b63 100644 --- a/app/soapbox/components/column_back_button.js +++ b/app/soapbox/components/column_back_button.js @@ -5,13 +5,19 @@ import Icon from 'soapbox/components/icon'; export default class ColumnBackButton extends React.PureComponent { + static propTypes = { + to: PropTypes.string, + }; + static contextTypes = { router: PropTypes.object, }; handleClick = () => { + const { to } = this.props; + if (window.history && window.history.length === 1) { - this.context.router.history.push('/'); + this.context.router.history.push(to ? to : '/'); } else { this.context.router.history.goBack(); } diff --git a/app/soapbox/features/account_timeline/index.js b/app/soapbox/features/account_timeline/index.js index ba0040623..d14899ad2 100644 --- a/app/soapbox/features/account_timeline/index.js +++ b/app/soapbox/features/account_timeline/index.js @@ -54,6 +54,7 @@ const makeMapStateToProps = () => { unavailable, accountUsername, accountApId, + isBlocked, isAccount: !!state.getIn(['accounts', accountId]), statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }), featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(), @@ -142,7 +143,7 @@ class AccountTimeline extends ImmutablePureComponent { } render() { - const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, accountId, unavailable, accountUsername } = this.props; + const { statusIds, featuredStatusIds, isLoading, hasMore, isBlocked, isAccount, accountId, unavailable, accountUsername } = this.props; const { collapsed, animating } = this.state; if (!isAccount && accountId !== -1) { @@ -165,7 +166,8 @@ class AccountTimeline extends ImmutablePureComponent { return (
- + {isBlocked ? + : }
); diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js index 2d9f155fd..9823978c9 100644 --- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js +++ b/app/soapbox/features/compose/components/emoji_picker_dropdown.js @@ -7,8 +7,6 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { supportsPassiveEvents } from 'detect-passive-events'; import { buildCustomEmojis } from '../../emoji/emoji'; -import { join } from 'path'; -import { FE_SUBDIRECTORY } from 'soapbox/build_config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -29,7 +27,7 @@ const messages = defineMessages({ let EmojiPicker, Emoji; // load asynchronously -const backgroundImageFn = () => join(FE_SUBDIRECTORY, 'emoji', 'sheet_13.png'); +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const categoriesSort = [ @@ -358,8 +356,8 @@ class EmojiPickerDropdown extends React.PureComponent {
๐Ÿ™‚
diff --git a/app/soapbox/features/crypto_donate/utils/coin_icons.js b/app/soapbox/features/crypto_donate/utils/coin_icons.js index 2c0376bfa..39fe39ded 100644 --- a/app/soapbox/features/crypto_donate/utils/coin_icons.js +++ b/app/soapbox/features/crypto_donate/utils/coin_icons.js @@ -1,20 +1,4 @@ -// Does some trickery to import all the icons into the project -// See: https://stackoverflow.com/questions/42118296/dynamically-import-images-from-a-directory-using-webpack - -const icons = {}; - -function importAll(r) { - const pathRegex = /\.\/(.*)\.svg/i; - - r.keys().forEach((key) => { - const ticker = pathRegex.exec(key)[1]; - return icons[ticker] = r(key).default; - }); -} - -importAll(require.context('cryptocurrency-icons/svg/color/', true, /\.svg$/)); - -export default icons; - // For getting the icon -export const getCoinIcon = ticker => icons[ticker] || icons.generic || null; +export const getCoinIcon = ticker => { + return require(`cryptocurrency-icons/svg/color/${ticker.toLowerCase()}.svg`); +}; diff --git a/app/soapbox/features/emoji/__tests__/emoji-test.js b/app/soapbox/features/emoji/__tests__/emoji-test.js index c8425c4c6..ce8d4e2a8 100644 --- a/app/soapbox/features/emoji/__tests__/emoji-test.js +++ b/app/soapbox/features/emoji/__tests__/emoji-test.js @@ -22,23 +22,23 @@ describe('emoji', () => { it('does unicode', () => { expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual( - '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ'); + '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ'); expect(emojify('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง')).toEqual( - '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง'); - expect(emojify('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ')).toEqual('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ'); + '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง'); + expect(emojify('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ')).toEqual('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ'); expect(emojify('\u2757')).toEqual( - 'โ—'); + 'โ—'); }); it('does multiple unicode', () => { expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual( - 'โ— #๏ธโƒฃ'); + 'โ— #๏ธโƒฃ'); expect(emojify('\u2757#\uFE0F\u20E3')).toEqual( - 'โ—#๏ธโƒฃ'); + 'โ—#๏ธโƒฃ'); expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual( - 'โ— #๏ธโƒฃ โ—'); + 'โ— #๏ธโƒฃ โ—'); expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual( - 'foo โ— #๏ธโƒฃ bar'); + 'foo โ— #๏ธโƒฃ bar'); }); it('ignores unicode inside of tags', () => { @@ -46,16 +46,16 @@ describe('emoji', () => { }); it('does multiple emoji properly (issue 5188)', () => { - expect(emojify('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•')).toEqual('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•'); - expect(emojify('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•')).toEqual('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•'); + expect(emojify('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•')).toEqual('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•'); + expect(emojify('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•')).toEqual('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•'); }); it('does an emoji that has no shortcode', () => { - expect(emojify('๐Ÿ‘โ€๐Ÿ—จ')).toEqual('๐Ÿ‘โ€๐Ÿ—จ'); + expect(emojify('๐Ÿ‘โ€๐Ÿ—จ')).toEqual('๐Ÿ‘โ€๐Ÿ—จ'); }); it('does an emoji whose filename is irregular', () => { - expect(emojify('โ†™๏ธ')).toEqual('โ†™๏ธ'); + expect(emojify('โ†™๏ธ')).toEqual('โ†™๏ธ'); }); it('avoid emojifying on invisible text', () => { @@ -67,16 +67,16 @@ describe('emoji', () => { it('avoid emojifying on invisible text with nested tags', () => { expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); + .toEqual('๐Ÿ˜‡'); expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); + .toEqual('๐Ÿ˜‡'); expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); + .toEqual('๐Ÿ˜‡'); }); it('skips the textual presentation VS15 character', () => { expect(emojify('โœด๏ธŽ')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15 - .toEqual('โœด'); + .toEqual('โœด'); }); }); }); diff --git a/app/soapbox/features/emoji/emoji.js b/app/soapbox/features/emoji/emoji.js index eb0df79a7..fe1029c59 100644 --- a/app/soapbox/features/emoji/emoji.js +++ b/app/soapbox/features/emoji/emoji.js @@ -1,7 +1,5 @@ import unicodeMapping from './emoji_unicode_mapping_light'; import Trie from 'substring-trie'; -import { join } from 'path'; -import { FE_SUBDIRECTORY } from 'soapbox/build_config'; const trie = new Trie(Object.keys(unicodeMapping)); @@ -62,7 +60,8 @@ const emojify = (str, customEmojis = {}, autoplay = false) => { } else { // matched to unicode emoji const { filename, shortCode } = unicodeMapping[match]; const title = shortCode ? `:${shortCode}:` : ''; - replacement = `${match}`; + const src = require(`twemoji/assets/svg/${filename}.svg`); + replacement = `${match}`; rend = i + match.length; // If the matched character was followed by VS15 (for selecting text presentation), skip it. if (str.codePointAt(rend) === 65038) { diff --git a/app/soapbox/features/favourited_statuses/index.js b/app/soapbox/features/favourited_statuses/index.js index 09907434d..be87190fa 100644 --- a/app/soapbox/features/favourited_statuses/index.js +++ b/app/soapbox/features/favourited_statuses/index.js @@ -15,7 +15,7 @@ import LoadingIndicator from '../../components/loading_indicator'; const mapStateToProps = (state, { params }) => { const username = params.username || ''; const me = state.get('me'); - const meUsername = state.getIn(['accounts', me, 'username']); + const meUsername = state.getIn(['accounts', me, 'username'], ''); const isMyAccount = (username.toLowerCase() === meUsername.toLowerCase()); diff --git a/app/soapbox/features/pinned_statuses/index.js b/app/soapbox/features/pinned_statuses/index.js index dd5447051..bfa57417e 100644 --- a/app/soapbox/features/pinned_statuses/index.js +++ b/app/soapbox/features/pinned_statuses/index.js @@ -12,7 +12,7 @@ import MissingIndicator from 'soapbox/components/missing_indicator'; const mapStateToProps = (state, { params }) => { const username = params.username || ''; const me = state.get('me'); - const meUsername = state.getIn(['accounts', me, 'username']); + const meUsername = state.getIn(['accounts', me, 'username'], ''); return { isMyAccount: (username.toLowerCase() === meUsername.toLowerCase()), statusIds: state.getIn(['status_lists', 'pins', 'items']), diff --git a/app/soapbox/features/reactions/index.js b/app/soapbox/features/reactions/index.js new file mode 100644 index 000000000..8e3ab9884 --- /dev/null +++ b/app/soapbox/features/reactions/index.js @@ -0,0 +1,120 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import MissingIndicator from '../../components/missing_indicator'; +import { fetchFavourites, fetchReactions } from '../../actions/interactions'; +import { fetchStatus } from '../../actions/statuses'; +import { FormattedMessage } from 'react-intl'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ScrollableList from '../../components/scrollable_list'; +import { makeGetStatus } from '../../selectors'; + +const mapStateToProps = (state, props) => { + const getStatus = makeGetStatus(); + const status = getStatus(state, { + id: props.params.statusId, + username: props.params.username, + }); + + const favourites = state.getIn(['user_lists', 'favourited_by', props.params.statusId]); + const reactions = state.getIn(['user_lists', 'reactions', props.params.statusId]); + const allReactions = favourites && reactions && ImmutableOrderedSet(favourites ? [{ accounts: favourites, count: favourites.size, name: '๐Ÿ‘' }] : []).union(reactions || []); + + return { + status, + reactions: allReactions, + accounts: allReactions && (props.params.reaction + ? allReactions.find(reaction => reaction.name === props.params.reaction).accounts.map(account => ({ id: account, reaction: props.params.reaction })) + : allReactions.map(reaction => reaction.accounts.map(account => ({ id: account, reaction: reaction.name }))).flatten()), + }; +}; + +export default @connect(mapStateToProps) +class Reactions extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + reactions: ImmutablePropTypes.orderedSet, + accounts: ImmutablePropTypes.orderedSet, + status: ImmutablePropTypes.map, + }; + + componentDidMount() { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + this.props.dispatch(fetchReactions(this.props.params.statusId)); + this.props.dispatch(fetchStatus(this.props.params.statusId)); + } + + componentDidUpdate(prevProps) { + const { params } = this.props; + if (params.statusId !== prevProps.params.statusId && params.statusId) { + this.props.dispatch(fetchFavourites(this.props.params.statusId)); + prevProps.dispatch(fetchReactions(params.statusId)); + prevProps.dispatch(fetchStatus(params.statusId)); + } + } + + handleFilterChange = (reaction) => () => { + const { params } = this.props; + const { username, statusId } = params; + + this.context.router.history.replace(`/@${username}/posts/${statusId}/reactions/${reaction}`); + }; + + render() { + const { params, reactions, accounts, status } = this.props; + const { username, statusId } = params; + + const back = `/@${username}/posts/${statusId}`; + + if (!accounts) { + return ( + + + + ); + } + + if (!status) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + { + reactions.size > 0 && ( +
+ + {reactions?.filter(reaction => reaction.count).map(reaction => )} +
+ ) + } + + {accounts.map((account) => + , + )} + +
+ ); + } + +} diff --git a/app/soapbox/features/soapbox_config/index.js b/app/soapbox/features/soapbox_config/index.js index b0199c449..70da9c18b 100644 --- a/app/soapbox/features/soapbox_config/index.js +++ b/app/soapbox/features/soapbox_config/index.js @@ -51,6 +51,8 @@ const messages = defineMessages({ displayFqnLabel: { id: 'soapbox_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' }, greentextLabel: { id: 'soapbox_config.greentext_label', defaultMessage: 'Enable greentext support' }, promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' }, + authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' }, + authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' }, }); const listenerOptions = supportsPassiveEvents ? { passive: true } : false; @@ -279,6 +281,13 @@ class SoapboxConfig extends ImmutablePureComponent { checked={soapbox.get('greentext') === true} onChange={this.handleChange(['greentext'], (e) => e.target.checked)} /> + e.target.checked)} + />
diff --git a/app/soapbox/features/status/components/status_interaction_bar.js b/app/soapbox/features/status/components/status_interaction_bar.js index d921efca0..186bec37d 100644 --- a/app/soapbox/features/status/components/status_interaction_bar.js +++ b/app/soapbox/features/status/components/status_interaction_bar.js @@ -1,18 +1,26 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import { FormattedNumber } from 'react-intl'; import emojify from 'soapbox/features/emoji/emoji'; import { reduceEmoji } from 'soapbox/utils/emoji_reacts'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; +import { getFeatures } from 'soapbox/utils/features'; import { Link } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -const mapStateToProps = state => ({ - allowedEmoji: getSoapboxConfig(state).get('allowedEmoji'), -}); +const mapStateToProps = state => { + const instance = state.get('instance'); + const features = getFeatures(instance); + + return { + allowedEmoji: getSoapboxConfig(state).get('allowedEmoji'), + reactionList: features.exposableReactions, + }; +}; export default @connect(mapStateToProps) class StatusInteractionBar extends ImmutablePureComponent { @@ -21,6 +29,7 @@ class StatusInteractionBar extends ImmutablePureComponent { status: ImmutablePropTypes.map, me: SoapboxPropTypes.me, allowedEmoji: ImmutablePropTypes.list, + reactionList: PropTypes.bool, } getNormalizedReacts = () => { @@ -49,35 +58,53 @@ class StatusInteractionBar extends ImmutablePureComponent { return ''; } - render() { + getEmojiReacts = () => { + const { status, reactionList } = this.props; + const emojiReacts = this.getNormalizedReacts(); const count = emojiReacts.reduce((acc, cur) => ( acc + cur.get('count') ), 0); - const repost = this.getRepost(); - const EmojiReactsContainer = () => ( -
-
- {emojiReacts.map((e, i) => ( - - - {e.get('count')} - - ))} + if (count > 0) { + return ( +
+
+ {emojiReacts.map((e, i) => { + const emojiReact = ( + <> + + {e.get('count')} + + ); + + if (reactionList) { + return {emojiReact}; + } + + return {emojiReact}; + })} +
+
+ {count} +
-
- {count} -
-
- ); + ); + } + + return ''; + }; + + render() { + const emojiReacts = this.getEmojiReacts(); + const repost = this.getRepost(); return (
- {count > 0 && } + {emojiReacts} {repost}
); diff --git a/app/soapbox/features/ui/components/column.js b/app/soapbox/features/ui/components/column.js index 5f31399e3..e915e5e70 100644 --- a/app/soapbox/features/ui/components/column.js +++ b/app/soapbox/features/ui/components/column.js @@ -12,12 +12,13 @@ export default class Column extends React.PureComponent { children: PropTypes.node, active: PropTypes.bool, backBtnSlim: PropTypes.bool, + back: PropTypes.string, }; render() { - const { heading, icon, children, active, backBtnSlim } = this.props; + const { heading, icon, children, active, backBtnSlim, back } = this.props; const columnHeaderId = heading && heading.replace(/ /g, '-'); - const backBtn = backBtnSlim ? () : (); + const backBtn = backBtnSlim ? () : (); return (
diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index fc00c7702..af09692bb 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { Switch, withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import NotificationsContainer from './containers/notifications_container'; import LoadingBarContainer from './containers/loading_bar_container'; @@ -44,6 +45,7 @@ import ProfileHoverCard from 'soapbox/components/profile_hover_card'; import { getAccessToken } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis'; +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { Status, @@ -56,6 +58,7 @@ import { Followers, Following, Reblogs, + Reactions, // Favourites, DirectTimeline, HashtagTimeline, @@ -120,6 +123,7 @@ const mapStateToProps = state => { const me = state.get('me'); const account = state.getIn(['accounts', me]); const instance = state.get('instance'); + const soapbox = getSoapboxConfig(state); return { dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, @@ -128,6 +132,7 @@ const mapStateToProps = state => { me, account, features: getFeatures(instance), + soapbox, }; }; @@ -165,6 +170,7 @@ class SwitchingColumnsArea extends React.PureComponent { children: PropTypes.node, location: PropTypes.object, onLayoutChange: PropTypes.func.isRequired, + soapbox: ImmutablePropTypes.map.isRequired, }; state = { @@ -193,7 +199,8 @@ class SwitchingColumnsArea extends React.PureComponent { } render() { - const { children } = this.props; + const { children, soapbox } = this.props; + const authenticatedProfile = soapbox.get('authenticatedProfile'); return ( @@ -253,15 +260,16 @@ class SwitchingColumnsArea extends React.PureComponent { - - - - + + + + + @@ -312,6 +320,7 @@ class UI extends React.PureComponent { streamingUrl: PropTypes.string, account: PropTypes.object, features: PropTypes.object.isRequired, + soapbox: ImmutablePropTypes.map.isRequired, }; state = { @@ -592,7 +601,7 @@ class UI extends React.PureComponent { } render() { - const { streamingUrl, features } = this.props; + const { streamingUrl, features, soapbox } = this.props; const { draggingOver, mobile } = this.state; const { intl, children, location, dropdownMenuIsOpen, me } = this.props; @@ -642,7 +651,7 @@ class UI extends React.PureComponent {
- + {children} diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index 0a4f44405..fb98c4f5b 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -94,6 +94,10 @@ export function Reblogs() { return import(/* webpackChunkName: "features/reblogs" */'../../reblogs'); } +export function Reactions() { + return import(/* webpackChunkName: "features/reactions" */'../../reactions'); +} + export function Favourites() { return import(/* webpackChunkName: "features/favourites" */'../../favourites'); } diff --git a/app/soapbox/middleware/sounds.js b/app/soapbox/middleware/sounds.js index a2fc7572f..6950e7618 100644 --- a/app/soapbox/middleware/sounds.js +++ b/app/soapbox/middleware/sounds.js @@ -1,8 +1,5 @@ 'use strict'; -import { join } from 'path'; -import { FE_SUBDIRECTORY } from 'soapbox/build_config'; - const createAudio = sources => { const audio = new Audio(); sources.forEach(({ type, src }) => { @@ -31,21 +28,21 @@ export default function soundsMiddleware() { const soundCache = { boop: createAudio([ { - src: join(FE_SUBDIRECTORY, '/sounds/boop.ogg'), + src: require('../../sounds/boop.ogg'), type: 'audio/ogg', }, { - src: join(FE_SUBDIRECTORY, '/sounds/boop.mp3'), + src: require('../../sounds/boop.mp3'), type: 'audio/mpeg', }, ]), chat: createAudio([ { - src: join(FE_SUBDIRECTORY, '/sounds/chat.oga'), + src: require('../../sounds/chat.oga'), type: 'audio/ogg', }, { - src: join(FE_SUBDIRECTORY, '/sounds/chat.mp3'), + src: require('../../sounds/chat.mp3'), type: 'audio/mpeg', }, ]), diff --git a/app/soapbox/reducers/__tests__/user_lists-test.js b/app/soapbox/reducers/__tests__/user_lists-test.js index feaaca3e6..7d571e208 100644 --- a/app/soapbox/reducers/__tests__/user_lists-test.js +++ b/app/soapbox/reducers/__tests__/user_lists-test.js @@ -10,6 +10,7 @@ describe('user_lists reducer', () => { favourited_by: ImmutableMap(), follow_requests: ImmutableMap(), blocks: ImmutableMap(), + reactions: ImmutableMap(), mutes: ImmutableMap(), groups: ImmutableMap(), groups_removed_accounts: ImmutableMap(), diff --git a/app/soapbox/reducers/user_lists.js b/app/soapbox/reducers/user_lists.js index 9afaf55b9..f0d80de77 100644 --- a/app/soapbox/reducers/user_lists.js +++ b/app/soapbox/reducers/user_lists.js @@ -14,6 +14,7 @@ import { import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS, + REACTIONS_FETCH_SUCCESS, } from '../actions/interactions'; import { BLOCKS_FETCH_SUCCESS, @@ -37,6 +38,7 @@ const initialState = ImmutableMap({ following: ImmutableMap(), reblogged_by: ImmutableMap(), favourited_by: ImmutableMap(), + reactions: ImmutableMap(), follow_requests: ImmutableMap(), blocks: ImmutableMap(), mutes: ImmutableMap(), @@ -77,6 +79,8 @@ export default function userLists(state = initialState, action) { return state.setIn(['reblogged_by', action.id], ImmutableOrderedSet(action.accounts.map(item => item.id))); case FAVOURITES_FETCH_SUCCESS: return state.setIn(['favourited_by', action.id], ImmutableOrderedSet(action.accounts.map(item => item.id))); + case REACTIONS_FETCH_SUCCESS: + return state.setIn(['reactions', action.id], action.reactions.map(({ accounts, ...reaction }) => ({ ...reaction, accounts: ImmutableOrderedSet(accounts.map(account => account.id)) }))); case NOTIFICATIONS_UPDATE: return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: diff --git a/app/soapbox/utils/features.js b/app/soapbox/utils/features.js index 518f3e669..86a02479a 100644 --- a/app/soapbox/utils/features.js +++ b/app/soapbox/utils/features.js @@ -25,6 +25,7 @@ export const getFeatures = createSelector([ settingsStore: v.software === 'Pleroma', accountAliasesAPI: v.software === 'Pleroma', resetPasswordAPI: v.software === 'Pleroma', + exposableReactions: features.includes('exposable_reactions'), }; }); diff --git a/app/styles/accounts.scss b/app/styles/accounts.scss index ad2190be5..14f74f59a 100644 --- a/app/styles/accounts.scss +++ b/app/styles/accounts.scss @@ -216,6 +216,13 @@ .account__avatar-wrapper { float: left; margin-right: 12px; + + .emoji-react__emoji { + position: absolute; + top: 36px; + left: 32px; + z-index: 1; + } } .account__avatar { diff --git a/app/styles/components/emoji-reacts.scss b/app/styles/components/emoji-reacts.scss index 4fca2108c..bc69b0542 100644 --- a/app/styles/components/emoji-reacts.scss +++ b/app/styles/components/emoji-reacts.scss @@ -1,6 +1,8 @@ .emoji-react { display: inline-block; transition: 0.1s; + color: var(--primary-text-color--faint); + text-decoration: none; &__emoji { img { @@ -20,8 +22,6 @@ } .emoji-react--reblogs { - color: var(--primary-text-color--faint); - text-decoration: none; vertical-align: middle; display: inline-flex; diff --git a/app/styles/ui.scss b/app/styles/ui.scss index d710bc180..8a111f31e 100644 --- a/app/styles/ui.scss +++ b/app/styles/ui.scss @@ -613,7 +613,8 @@ .notification__filter-bar, .search__filter-bar, -.account__section-headline { +.account__section-headline, +.reaction__filter-bar { border-bottom: 1px solid var(--brand-color--faint); cursor: default; display: flex; @@ -663,6 +664,17 @@ } } +.reaction__filter-bar { + overflow-x: auto; + overflow-y: hidden; + + a { + flex: unset; + padding: 15px 24px; + min-width: max-content; + } +} + ::-webkit-scrollbar-thumb { border-radius: 0; } diff --git a/jest.config.js b/jest.config.js index d7b2fb44a..efe164457 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,4 +31,11 @@ module.exports = { '/app', ], 'testEnvironment': 'jsdom', + 'moduleNameMapper': { + '^.+.(css|styl|less|sass|scss|png|jpg|svg|ttf|woff|woff2)$': 'jest-transform-stub', + }, + 'transform': { + '\\.[jt]sx?$': 'babel-jest', + '.+\\.(css|styl|less|sass|scss|png|jpg|svg|ttf|woff|woff2)$': 'jest-transform-stub', + }, }; diff --git a/package.json b/package.json index 018088f2d..419294299 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "intl-messageformat-parser": "^6.0.0", "intl-pluralrules": "^1.3.0", "is-nan": "^1.2.1", + "jest-transform-stub": "^2.0.0", "jsdoc": "~3.6.7", "lodash": "^4.7.11", "mark-loader": "^0.1.6", diff --git a/webpack/configuration.js b/webpack/configuration.js index 0089469c4..960e25e60 100644 --- a/webpack/configuration.js +++ b/webpack/configuration.js @@ -12,7 +12,6 @@ const settings = { test_root_path: `${FE_BUILD_DIR}-test`, cache_path: 'tmp/cache', resolved_paths: [], - static_assets_extensions: [ '.jpg', '.jpeg', '.png', '.tiff', '.ico', '.svg', '.gif', '.eot', '.otf', '.ttf', '.woff', '.woff2', '.mp3', '.ogg', '.oga' ], extensions: [ '.mjs', '.js', '.sass', '.scss', '.css', '.module.sass', '.module.scss', '.module.css', '.png', '.svg', '.gif', '.jpeg', '.jpg' ], }; diff --git a/webpack/production.js b/webpack/production.js index 9968a2ca9..cccfc4163 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -25,37 +25,33 @@ module.exports = merge(sharedConfig, { new OfflinePlugin({ caches: { main: [':rest:'], - additional: [':externals:'], + additional: [ + 'packs/emoji/1f602-*.svg', // used for emoji picker dropdown + 'packs/images/32-*.png', // used in emoji-mart + + // Default emoji reacts + 'packs/emoji/1f44d-*.svg', // Thumbs up + 'packs/emoji/2764-*.svg', // Heart + 'packs/emoji/1f606-*.svg', // Laughing + 'packs/emoji/1f62e-*.svg', // Surprised + 'packs/emoji/1f622-*.svg', // Crying + 'packs/emoji/1f629-*.svg', // Weary + 'packs/emoji/1f621-*.svg', // Angry (Spinster) + ], optional: [ '**/locale_*.js', // don't fetch every locale; the user only needs one '**/*_polyfills-*.js', // the user may not need polyfills '**/*.chunk.js', // only cache chunks when needed '**/*.woff2', // the user may have system-fonts enabled - // images/audio can be cached on-demand + // images can be cached on-demand '**/*.png', - '**/*.jpg', - '**/*.jpeg', '**/*.svg', - '**/*.mp3', - '**/*.ogg', ], }, - externals: [ - '/emoji/1f602.svg', // used for emoji picker dropdown - '/emoji/sheet_13.png', // used in emoji-mart - - // Default emoji reacts - '/emoji/1f44d.svg', // Thumbs up - '/emoji/2764.svg', // Heart - '/emoji/1f606.svg', // Laughing - '/emoji/1f62e.svg', // Surprised - '/emoji/1f622.svg', // Crying - '/emoji/1f629.svg', // Weary - '/emoji/1f621.svg', // Angry (Spinster) - ], excludes: [ '**/*.gz', '**/*.map', + '**/*.LICENSE.txt', 'stats.json', 'report.html', 'instance/**/*', @@ -66,15 +62,20 @@ module.exports = merge(sharedConfig, { '**/*.woff', // Sounds return a 206 causing sw.js to crash // https://stackoverflow.com/a/66335638 - 'sounds/**/*', - // Don't cache index.html + '**/*.ogg', + '**/*.oga', + '**/*.mp3', + // Don't serve index.html + // https://github.com/bromite/bromite/issues/1294 'index.html', + '404.html', ], // ServiceWorker: { // entry: join(__dirname, '../app/soapbox/service_worker/entry.js'), // cacheName: 'soapbox', // minify: true, // }, + safeToUseOptionalCaches: true, }), ], }); diff --git a/webpack/rules/assets.js b/webpack/rules/assets.js new file mode 100644 index 000000000..2c6fb3f0d --- /dev/null +++ b/webpack/rules/assets.js @@ -0,0 +1,50 @@ +// Asset modules +// https://webpack.js.org/guides/asset-modules/ + +const { resolve } = require('path'); + +// These are processed in reverse-order +// We use the name 'packs' instead of 'assets' for legacy reasons +module.exports = [{ + test: /\.(png|svg)/, + type: 'asset/resource', + include: [ + resolve('app', 'images'), + resolve('node_modules', 'emoji-datasource'), + ], + generator: { + filename: 'packs/images/[name]-[contenthash:8][ext]', + }, +}, { + test: /\.(ttf|eot|svg|woff|woff2)/, + type: 'asset/resource', + include: [ + resolve('app', 'fonts'), + resolve('node_modules', 'fork-awesome'), + resolve('node_modules', '@fontsource'), + ], + generator: { + filename: 'packs/fonts/[name]-[contenthash:8][ext]', + }, +}, { + test: /\.(ogg|oga|mp3)/, + type: 'asset/resource', + include: resolve('app', 'sounds'), + generator: { + filename: 'packs/sounds/[name]-[contenthash:8][ext]', + }, +}, { + test: /\.svg$/, + type: 'asset/resource', + include: resolve('node_modules', 'twemoji'), + generator: { + filename: 'packs/emoji/[name]-[contenthash:8][ext]', + }, +}, { + test: /\.svg$/, + type: 'asset/resource', + include: resolve('node_modules', 'cryptocurrency-icons'), + generator: { + filename: 'packs/images/crypto/[name]-[contenthash:8][ext]', + }, +}]; diff --git a/webpack/rules/file.js b/webpack/rules/file.js deleted file mode 100644 index d23a0a977..000000000 --- a/webpack/rules/file.js +++ /dev/null @@ -1,20 +0,0 @@ -const { join } = require('path'); -const { settings } = require('../configuration'); - -module.exports = { - test: new RegExp(`(${settings.static_assets_extensions.join('|')})$`, 'i'), - use: [ - { - loader: 'file-loader', - options: { - name(file) { - if (file.includes(settings.source_path)) { - return 'packs/media/[path][name]-[contenthash].[ext]'; - } - return 'packs/media/[folder]/[name]-[contenthash:8].[ext]'; - }, - context: join(settings.source_path), - }, - }, - ], -}; diff --git a/webpack/rules/index.js b/webpack/rules/index.js index 91a4abd19..d3290659e 100644 --- a/webpack/rules/index.js +++ b/webpack/rules/index.js @@ -3,14 +3,14 @@ const git = require('./babel-git'); const gitRefresh = require('./git-refresh'); const buildConfig = require('./babel-build-config'); const css = require('./css'); -const file = require('./file'); +const assets = require('./assets'); const nodeModules = require('./node_modules'); // Webpack loaders are processed in reverse order // https://webpack.js.org/concepts/loaders/#loader-features // Lastly, process static files using file loader module.exports = [ - file, + ...assets, css, nodeModules, babel, diff --git a/webpack/shared.js b/webpack/shared.js index c0bbad931..0c4bd856e 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -30,10 +30,9 @@ const makeHtmlConfig = (params = {}) => { }; module.exports = { - entry: Object.assign( - { application: resolve('app/application.js') }, - { styles: resolve(join(settings.source_path, 'styles/application.scss')) }, - ), + entry: { + application: resolve('app/application.js'), + }, output: { filename: 'packs/js/[name]-[chunkhash].js', @@ -65,7 +64,7 @@ module.exports = { }, module: { - rules: Object.keys(rules).map(key => rules[key]), + rules, }, plugins: [ @@ -89,15 +88,6 @@ module.exports = { new HtmlWebpackHarddiskPlugin(), new CopyPlugin({ patterns: [{ - from: join(__dirname, '../node_modules/twemoji/assets/svg'), - to: join(output.path, 'emoji'), - }, { - from: join(__dirname, '../node_modules/emoji-datasource/img/twitter/sheets/32.png'), - to: join(output.path, 'emoji/sheet_13.png'), - }, { - from: join(__dirname, '../app/sounds'), - to: join(output.path, 'sounds'), - }, { from: join(__dirname, '../app/instance'), to: join(output.path, 'instance'), }], diff --git a/yarn.lock b/yarn.lock index 4876a41de..9e75304e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7239,6 +7239,11 @@ jest-snapshot@^27.1.0: pretty-format "^27.1.0" semver "^7.3.2" +jest-transform-stub@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jest-transform-stub/-/jest-transform-stub-2.0.0.tgz#19018b0851f7568972147a5d60074b55f0225a7d" + integrity sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg== + jest-util@^27.0.0: version "27.0.6" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-27.0.6.tgz#e8e04eec159de2f4d5f57f795df9cdc091e50297"