Merge branch 'emojireacts' into 'master'

Emoji reactions

See merge request soapbox-pub/soapbox-fe!18
merge-requests/12/merge
Alex Gleason 4 years ago
commit 963c68c642

@ -0,0 +1,177 @@
import api from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { favourite, unfavourite } from './interactions';
export const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST';
export const EMOJI_REACT_SUCCESS = 'EMOJI_REACT_SUCCESS';
export const EMOJI_REACT_FAIL = 'EMOJI_REACT_FAIL';
export const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST';
export const UNEMOJI_REACT_SUCCESS = 'UNEMOJI_REACT_SUCCESS';
export const UNEMOJI_REACT_FAIL = 'UNEMOJI_REACT_FAIL';
export const EMOJI_REACTS_FETCH_REQUEST = 'EMOJI_REACTS_FETCH_REQUEST';
export const EMOJI_REACTS_FETCH_SUCCESS = 'EMOJI_REACTS_FETCH_SUCCESS';
export const EMOJI_REACTS_FETCH_FAIL = 'EMOJI_REACTS_FETCH_FAIL';
const noOp = () => () => new Promise(f => f());
export const simpleEmojiReact = (status, emoji) => {
return (dispatch, getState) => {
const emojiReacts = status.getIn(['pleroma', 'emoji_reactions']);
if (emoji === '👍' && status.get('favourited')) return dispatch(unfavourite(status));
const undo = emojiReacts.filter(e => e.get('me') === true && e.get('name') === emoji).count() > 0;
if (undo) return dispatch(unEmojiReact(status, emoji));
return Promise.all(
emojiReacts
.filter(emojiReact => emojiReact.get('me') === true)
.map(emojiReact => dispatch(unEmojiReact(status, emojiReact.get('name')))),
status.get('favourited') && dispatch(unfavourite(status))
).then(() => {
if (emoji === '👍') {
dispatch(favourite(status));
} else {
dispatch(emojiReact(status, emoji));
}
}).catch(err => {
console.error(err);
});
};
};
export function fetchEmojiReacts(id, emoji) {
return (dispatch, getState) => {
if (!getState().get('me')) return dispatch(noOp());
dispatch(fetchEmojiReactsRequest(id, emoji));
const url = emoji
? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
: `/api/v1/pleroma/statuses/${id}/reactions`;
return api(getState).get(url).then(response => {
response.data.forEach(emojiReact => {
dispatch(importFetchedAccounts(emojiReact.accounts));
});
dispatch(fetchEmojiReactsSuccess(id, response.data));
}).catch(error => {
dispatch(fetchEmojiReactsFail(id, error));
});
};
};
export function emojiReact(status, emoji) {
return function(dispatch, getState) {
if (!getState().get('me')) return dispatch(noOp());
dispatch(emojiReactRequest(status, emoji));
return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
.then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(emojiReactSuccess(status, emoji));
}).catch(function(error) {
dispatch(emojiReactFail(status, emoji, error));
});
};
};
export function unEmojiReact(status, emoji) {
return (dispatch, getState) => {
if (!getState().get('me')) return dispatch(noOp());
dispatch(unEmojiReactRequest(status, emoji));
return api(getState)
.delete(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
.then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unEmojiReactSuccess(status, emoji));
}).catch(error => {
dispatch(unEmojiReactFail(status, emoji, error));
});
};
};
export function fetchEmojiReactsRequest(id, emoji) {
return {
type: EMOJI_REACTS_FETCH_REQUEST,
id,
emoji,
};
};
export function fetchEmojiReactsSuccess(id, emojiReacts) {
return {
type: EMOJI_REACTS_FETCH_SUCCESS,
id,
emojiReacts,
};
};
export function fetchEmojiReactsFail(id, error) {
return {
type: EMOJI_REACTS_FETCH_FAIL,
error,
};
};
export function emojiReactRequest(status, emoji) {
return {
type: EMOJI_REACT_REQUEST,
status,
emoji,
skipLoading: true,
};
};
export function emojiReactSuccess(status, emoji) {
return {
type: EMOJI_REACT_SUCCESS,
status,
emoji,
skipLoading: true,
};
};
export function emojiReactFail(status, emoji, error) {
return {
type: EMOJI_REACT_FAIL,
status,
emoji,
error,
skipLoading: true,
};
};
export function unEmojiReactRequest(status, emoji) {
return {
type: UNEMOJI_REACT_REQUEST,
status,
emoji,
skipLoading: true,
};
};
export function unEmojiReactSuccess(status, emoji) {
return {
type: UNEMOJI_REACT_SUCCESS,
status,
emoji,
skipLoading: true,
};
};
export function unEmojiReactFail(status, emoji, error) {
return {
type: UNEMOJI_REACT_FAIL,
status,
emoji,
error,
skipLoading: true,
};
};

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ALLOWED_EMOJI } from 'gabsocial/utils/emoji_reacts';
import emojify from 'gabsocial/features/emoji/emoji';
import classNames from 'classnames';
export default class EmojiSelector extends React.Component {
static propTypes = {
onReact: PropTypes.func.isRequired,
visible: PropTypes.bool,
}
static defaultProps = {
onReact: () => {},
visible: false,
}
render() {
const { onReact, visible } = this.props;
return (
<div className={classNames('emoji-react-selector', { 'emoji-react-selector--visible': visible })}>
{ALLOWED_EMOJI.map((emoji, i) => (
<button
key={i}
className='emoji-react-selector__emoji'
dangerouslySetInnerHTML={{ __html: emojify(emoji) }}
onClick={onReact(emoji)}
/>
))}
</div>
);
}
}

@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'gabsocial/components/icon';
import emojify from 'gabsocial/features/emoji/emoji';
export default class IconButton extends React.PureComponent {
@ -12,6 +13,8 @@ export default class IconButton extends React.PureComponent {
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
@ -23,6 +26,8 @@ export default class IconButton extends React.PureComponent {
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
text: PropTypes.string,
emoji: PropTypes.string,
};
static defaultProps = {
@ -32,6 +37,9 @@ export default class IconButton extends React.PureComponent {
animate: false,
overlay: false,
tabIndex: '0',
onClick: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
};
handleClick = (e) => {
@ -64,6 +72,8 @@ export default class IconButton extends React.PureComponent {
pressed,
tabIndex,
title,
text,
emoji,
} = this.props;
const classes = classNames(className, 'icon-button', {
@ -84,11 +94,17 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
style={style}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} fixedWidth aria-hidden='true' />
<div style={style}>
{emoji
? <div className='icon-button__emoji' dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
: <Icon id={icon} fixedWidth aria-hidden='true' />}
</div>
{text && <span className='icon_button__text'>{text}</span>}
</button>
);
}
@ -103,11 +119,17 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
style={style}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
<div style={style}>
{emoji
? <div className='icon-button__emoji' style={{ transform: `rotate(${rotate}deg)` }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
: <Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />}
</div>
{text && <span className='icon_button__text'>{text}</span>}
</button>
)}
</Motion>

@ -131,7 +131,7 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='dollar' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.donate)}</span>
</NavLink>
: ''}
: ''}
<NavLink className='sidebar-menu-item' to='/lists' onClick={onClose}>
<Icon id='list' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.lists)}</span>

@ -11,6 +11,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { isStaff } from 'gabsocial/utils/accounts';
import { openModal } from '../actions/modal';
import { Link } from 'react-router-dom';
import EmojiSelector from 'gabsocial/components/emoji_selector';
import { getReactForStatus, reduceEmoji } from 'gabsocial/utils/emoji_reacts';
import { simpleEmojiReact } from 'gabsocial/actions/emoji_reacts';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -74,6 +77,10 @@ class StatusActionBar extends ImmutablePureComponent {
isStaff: false,
}
state = {
emojiSelectorVisible: false,
}
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
@ -99,6 +106,41 @@ class StatusActionBar extends ImmutablePureComponent {
});
}
isMobile = () => window.matchMedia('only screen and (max-width: 895px)').matches;
handleLikeButtonHover = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: true });
}
handleLikeButtonLeave = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: false });
}
handleLikeButtonClick = e => {
const meEmojiReact = getReactForStatus(this.props.status) || '👍';
if (this.isMobile()) {
if (this.state.emojiSelectorVisible) {
this.handleReactClick(meEmojiReact)();
} else {
this.setState({ emojiSelectorVisible: true });
}
} else {
this.handleReactClick(meEmojiReact)();
}
}
handleReactClick = emoji => {
return e => {
const { me, status } = this.props;
if (me) {
this.props.dispatch(simpleEmojiReact(status, emoji));
} else {
this.props.onOpenUnauthorizedModal();
}
this.setState({ emojiSelectorVisible: false });
};
}
handleFavouriteClick = () => {
const { me } = this.props;
if (me) {
@ -251,14 +293,32 @@ class StatusActionBar extends ImmutablePureComponent {
return menu;
}
setRef = c => {
this.node = c;
}
componentDidMount() {
document.addEventListener('click', e => {
if (this.node && !this.node.contains(e.target))
this.setState({ emojiSelectorVisible: false });
});
}
render() {
const { status, intl } = this.props;
const { emojiSelectorVisible } = this.state;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const replyCount = status.get('replies_count');
const reblogCount = status.get('reblogs_count');
const favoriteCount = status.get('favourites_count');
const favouriteCount = status.get('favourites_count');
const emojiReactCount = reduceEmoji(
status.getIn(['pleroma', 'emoji_reactions'], []),
favouriteCount,
status.get('favourited'),
).reduce((acc, cur) => acc + cur.get('count'), 0);
const meEmojiReact = getReactForStatus(status);
let menu = this._makeMenu(publicStatus);
let reblogIcon = 'retweet';
@ -293,9 +353,23 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
{reblogCount !== 0 && <Link to={`/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/reblogs`} className='detailed-status__link'>{reblogCount}</Link>}
</div>
<div className='status__action-bar__counter'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{favoriteCount !== 0 && <span className='detailed-status__link'>{favoriteCount}</span>}
<div
className='status__action-bar__counter status__action-bar__counter--favourite'
onMouseEnter={this.handleLikeButtonHover}
onMouseLeave={this.handleLikeButtonLeave}
ref={this.setRef}
>
<EmojiSelector onReact={this.handleReactClick} visible={emojiSelectorVisible} />
<IconButton
className='status__action-bar-button star-icon'
animate
active={Boolean(meEmojiReact)}
title={intl.formatMessage(messages.favourite)}
icon='thumbs-up'
emoji={meEmojiReact}
onClick={this.handleLikeButtonClick}
/>
{emojiReactCount !== 0 && <span className='detailed-status__link'>{emojiReactCount}</span>}
</div>
{shareButton}
@ -317,6 +391,7 @@ const mapStateToProps = state => {
};
const mapDispatchToProps = (dispatch) => ({
dispatch,
onOpenUnauthorizedModal() {
dispatch(openModal('UNAUTHORIZED'));
},

@ -8,6 +8,7 @@ import Permalink from '../../../components/permalink';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import Icon from 'gabsocial/components/icon';
import emojify from 'gabsocial/features/emoji/emoji';
const notificationForScreenReader = (intl, message, timestamp) => {
const output = [message];
@ -141,19 +142,51 @@ class Notification extends ImmutablePureComponent {
);
}
renderEmojiReact(notification, link) {
const { intl } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-emoji-react focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.emoji_react', defaultMessage: '{name} reacted to your post' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<span dangerouslySetInnerHTML={{ __html: emojify(emojify(notification.get('emoji'))) }} />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.emoji_react' defaultMessage='{name} reacted to your post' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={!!this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
renderFavourite(notification, link) {
const { intl } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favorited your post' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} liked your post' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='star' className='star-icon' fixedWidth />
<Icon id='thumbs-up' className='star-icon' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favorited your post' values={{ name: link }} />
<FormattedMessage id='notification.favourite' defaultMessage='{name} liked your post' values={{ name: link }} />
</span>
</div>
@ -254,6 +287,8 @@ class Notification extends ImmutablePureComponent {
return this.renderReblog(notification, link);
case 'poll':
return this.renderPoll(notification);
case 'pleroma:emoji_reaction':
return this.renderEmojiReact(notification, link);
}
return null;

@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { isStaff } from 'gabsocial/utils/accounts';
import EmojiSelector from 'gabsocial/components/emoji_selector';
import { getReactForStatus } from 'gabsocial/utils/emoji_reacts';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -59,6 +61,7 @@ class ActionBar extends React.PureComponent {
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onEmojiReact: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
@ -78,6 +81,10 @@ class ActionBar extends React.PureComponent {
isStaff: false,
}
state = {
emojiSelectorVisible: false,
}
handleReplyClick = () => {
const { me } = this.props;
if (me) {
@ -105,6 +112,41 @@ class ActionBar extends React.PureComponent {
}
}
isMobile = () => window.matchMedia('only screen and (max-width: 895px)').matches;
handleLikeButtonHover = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: true });
}
handleLikeButtonLeave = e => {
if (!this.isMobile()) this.setState({ emojiSelectorVisible: false });
}
handleLikeButtonClick = e => {
const meEmojiReact = getReactForStatus(this.props.status) || '👍';
if (this.isMobile()) {
if (this.state.emojiSelectorVisible) {
this.handleReactClick(meEmojiReact)();
} else {
this.setState({ emojiSelectorVisible: true });
}
} else {
this.handleReactClick(meEmojiReact)();
}
}
handleReactClick = emoji => {
return e => {
const { me } = this.props;
if (me) {
this.props.onEmojiReact(this.props.status, emoji);
} else {
this.props.onOpenUnauthorizedModal();
}
this.setState({ emojiSelectorVisible: false });
};
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@ -171,11 +213,24 @@ class ActionBar extends React.PureComponent {
}
}
setRef = c => {
this.node = c;
}
componentDidMount() {
document.addEventListener('click', e => {
if (this.node && !this.node.contains(e.target))
this.setState({ emojiSelectorVisible: false });
});
}
render() {
const { status, intl, me, isStaff } = this.props;
const { emojiSelectorVisible } = this.state;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
const meEmojiReact = getReactForStatus(status);
let menu = [];
@ -232,9 +287,42 @@ class ActionBar extends React.PureComponent {
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'>
<IconButton
title={intl.formatMessage(messages.reply)}
icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
onClick={this.handleReplyClick}
text={intl.formatMessage(messages.reply)}
/>
</div>
<div className='detailed-status__button'>
<IconButton
disabled={reblog_disabled}
active={status.get('reblogged')}
title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
icon={reblogIcon}
onClick={this.handleReblogClick}
text='Boost'
/>
</div>
<div
className='detailed-status__button detailed-status__button--favourite'
onMouseEnter={this.handleLikeButtonHover}
onMouseLeave={this.handleLikeButtonLeave}
ref={this.setRef}
>
<EmojiSelector onReact={this.handleReactClick} visible={emojiSelectorVisible} />
<IconButton
className='star-icon'
animate
active={Boolean(meEmojiReact)}
title={intl.formatMessage(messages.favourite)}
icon='thumbs-up'
emoji={meEmojiReact}
text='Like'
onClick={this.handleLikeButtonClick}
/>
</div>
{shareButton}
<div className='detailed-status__action-bar-dropdown'>

@ -14,6 +14,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames';
import Icon from 'gabsocial/components/icon';
import PollContainer from 'gabsocial/containers/poll_container';
import { StatusInteractionBar } from './status_interaction_bar';
export default class DetailedStatus extends ImmutablePureComponent {
@ -92,7 +93,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
@ -169,15 +169,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
);
}
favouriteLink = (
<span className='detailed-status__link'>
<Icon id='star' />
<span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} />
</span>
</span>
);
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
@ -197,9 +188,12 @@ export default class DetailedStatus extends ImmutablePureComponent {
{media}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{applicationLink} · {reblogLink} · {favouriteLink}
<StatusInteractionBar status={status} />
<div>
{reblogLink} {applicationLink} · <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>
</div>
</div>
</div>
</div>

@ -0,0 +1,55 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import emojify from 'gabsocial/features/emoji/emoji';
import { reduceEmoji } from 'gabsocial/utils/emoji_reacts';
import SoapboxPropTypes from 'gabsocial/utils/soapbox_prop_types';
export class StatusInteractionBar extends React.Component {
static propTypes = {
status: ImmutablePropTypes.map,
me: SoapboxPropTypes.me,
}
getNormalizedReacts = () => {
const { status } = this.props;
return reduceEmoji(
status.getIn(['pleroma', 'emoji_reactions']),
status.get('favourites_count'),
status.get('favourited'),
).reverse();
}
render() {
const emojiReacts = this.getNormalizedReacts();
const count = emojiReacts.reduce((acc, cur) => (
acc + cur.get('count')
), 0);
const EmojiReactsContainer = () => (
<div className='emoji-reacts-container'>
<div className='emoji-reacts'>
{emojiReacts.map((e, i) => (
<span className='emoji-react' key={i}>
<span
className='emoji-react__emoji'
dangerouslySetInnerHTML={{ __html: emojify(e.get('name')) }}
/>
<span className='emoji-react__count'>{e.get('count')}</span>
</span>
))}
</div>
<div className='emoji-reacts__count'>
{count}
</div>
</div>
);
return (
<div className='status-interaction-bar'>
{count > 0 && <EmojiReactsContainer />}
</div>
);
}
}

@ -17,6 +17,7 @@ import {
pin,
unpin,
} from '../../actions/interactions';
import { simpleEmojiReact } from '../../actions/emoji_reacts';
import {
replyCompose,
mentionCompose,
@ -161,6 +162,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
handleEmojiReactClick = (status, emoji) => {
this.props.dispatch(simpleEmojiReact(status, emoji));
}
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
@ -496,6 +501,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onEmojiReact={this.handleEmojiReactClick}
onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick}
onDirect={this.handleDirectClick}

@ -41,6 +41,7 @@ const notificationToMap = notification => ImmutableMap({
account: notification.account.id,
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
emoji: notification.emoji,
});
const normalizeNotification = (state, notification) => {

@ -10,9 +10,13 @@ import {
STATUS_REVEAL,
STATUS_HIDE,
} from '../actions/statuses';
import {
EMOJI_REACT_REQUEST,
} from '../actions/emoji_reacts';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
import { Map as ImmutableMap, fromJS } from 'immutable';
import { simulateEmojiReact } from 'gabsocial/utils/emoji_reacts';
const importStatus = (state, status) => state.set(status.id, fromJS(status));
@ -37,6 +41,10 @@ export default function statuses(state = initialState, action) {
return importStatuses(state, action.statuses);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case EMOJI_REACT_REQUEST:
const path = [action.status.get('id'), 'pleroma', 'emoji_reactions'];
const emojiReacts = state.getIn(path);
return state.setIn(path, simulateEmojiReact(emojiReacts, action.emoji));
case FAVOURITE_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:

@ -0,0 +1,207 @@
import {
sortEmoji,
mergeEmojiFavourites,
filterEmoji,
oneEmojiPerAccount,
reduceEmoji,
getReactForStatus,
simulateEmojiReact,
} from '../emoji_reacts';
import { fromJS } from 'immutable';
const ALLOWED_EMOJI = [
'👍',
'❤',
'😂',
'😯',
'😢',
'😡',
];
describe('filterEmoji', () => {
describe('with a mix of allowed and disallowed emoji', () => {
const emojiReacts = fromJS([
{ 'count': 1, 'me': true, 'name': '🌵' },
{ 'count': 1, 'me': true, 'name': '😂' },
{ 'count': 1, 'me': true, 'name': '👀' },
{ 'count': 1, 'me': true, 'name': '🍩' },
{ 'count': 1, 'me': true, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 1, 'me': true, 'name': '😠' },
]);
it('filters only allowed emoji', () => {
expect(filterEmoji(emojiReacts, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 1, 'me': true, 'name': '😂' },
{ 'count': 1, 'me': true, 'name': '😡' },
]));
});
});
});
describe('sortEmoji', () => {
describe('with an unsorted list of emoji', () => {
const emojiReacts = fromJS([
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' },
{ 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 15, 'me': true, 'name': '❤' },
]);
it('sorts the emoji by count', () => {
expect(sortEmoji(emojiReacts)).toEqual(fromJS([
{ 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 7, 'me': true, 'name': '😂' },
{ 'count': 3, 'me': true, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '😡' },
]));
});
});
});
describe('mergeEmojiFavourites', () => {
const favouritesCount = 12;
const favourited = true;
describe('with existing 👍 reacts', () => {
const emojiReacts = fromJS([
{ 'count': 20, 'me': false, 'name': '👍' },
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
]);
it('combines 👍 reacts with favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 32, 'me': true, 'name': '👍' },
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
]));
});
});
describe('without existing 👍 reacts', () => {
const emojiReacts = fromJS([
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
]);
it('adds 👍 reacts to the map equaling favourite count', () => {
expect(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)).toEqual(fromJS([
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
{ 'count': 12, 'me': true, 'name': '👍' },
]));
});
it('does not add 👍 reacts when there are no favourites', () => {
expect(mergeEmojiFavourites(emojiReacts, 0, false)).toEqual(fromJS([
{ 'count': 15, 'me': false, 'name': '❤' },
{ 'count': 7, 'me': false, 'name': '😯' },
]));
});
});
});
describe('reduceEmoji', () => {
describe('with a clusterfuck of emoji', () => {
const emojiReacts = fromJS([
{ 'count': 1, 'me': false, 'name': '😡' },
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 3, 'me': false, 'name': '😢' },
{ 'count': 1, 'me': true, 'name': '🌵' },
{ 'count': 20, 'me': true, 'name': '👍' },
{ 'count': 7, 'me': false, 'name': '😂' },
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 1, 'me': false, 'name': '👀' },
{ 'count': 1, 'me': false, 'name': '🍩' },
]);
it('sorts, filters, and combines emoji and favourites', () => {
expect(reduceEmoji(emojiReacts, 7, true, ALLOWED_EMOJI)).toEqual(fromJS([
{ 'count': 27, 'me': true, 'name': '👍' },
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 7, 'me': false, 'name': '😂' },
{ 'count': 3, 'me': false, 'name': '😢' },
{ 'count': 1, 'me': false, 'name': '😡' },
]));
});
});
});
describe('oneEmojiPerAccount', () => {
it('reduces to one react per account', () => {
const emojiReacts = fromJS([
// Sorted
{ 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 2, 'me': true, 'name': '❤', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 1, 'me': true, 'name': '😯', accounts: [{ id: '1' }] },
{ 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] },
]);
expect(oneEmojiPerAccount(emojiReacts, '1')).toEqual(fromJS([
{ 'count': 2, 'me': true, 'name': '👍', accounts: [{ id: '1' }, { id: '2' }] },
{ 'count': 1, 'me': false, 'name': '😂', accounts: [{ id: '3' }] },
]));
});
});
describe('getReactForStatus', () => {
it('returns a single owned react (including favourite) for the status', () => {
const status = fromJS({
favourited: false,
pleroma: {
emoji_reactions: [
{ 'count': 20, 'me': false, 'name': '👍' },
{ 'count': 15, 'me': true, 'name': '❤' },
{ 'count': 7, 'me': true, 'name': '😯' },
{ 'count': 7, 'me': false, 'name': '😂' },
],
},
});
expect(getReactForStatus(status)).toEqual('❤');
});
it('returns a thumbs-up for a favourite', () => {
const status = fromJS({ favourites_count: 1, favourited: true });
expect(getReactForStatus(status)).toEqual('👍');
});
it('returns undefined when a status has no reacts (or favourites)', () => {
const status = fromJS([]);
expect(getReactForStatus(status)).toEqual(undefined);
});
it('returns undefined when a status has no valid reacts (or favourites)', () => {
const status = fromJS([
{ 'count': 1, 'me': true, 'name': '🔪' },
{ 'count': 1, 'me': true, 'name': '🌵' },
{ 'count': 1, 'me': false, 'name': '👀' },
{ 'count': 1, 'me': false, 'name': '🍩' },
]);
expect(getReactForStatus(status)).toEqual(undefined);
});
});
describe('simulateEmojiReact', () => {
it('adds the emoji to the list', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
]);
expect(simulateEmojiReact(emojiReacts, '❤')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 3, 'me': true, 'name': '❤' },
]));
});
it('creates the emoji if it didn\'t already exist', () => {
const emojiReacts = fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
]);
expect(simulateEmojiReact(emojiReacts, '😯')).toEqual(fromJS([
{ 'count': 2, 'me': false, 'name': '👍' },
{ 'count': 2, 'me': false, 'name': '❤' },
{ 'count': 1, 'me': true, 'name': '😯' },
]));
});
});

@ -0,0 +1,102 @@
import {
Map as ImmutableMap,
List as ImmutableList,
} from 'immutable';
// https://emojipedia.org/facebook
// I've customized them.
export const ALLOWED_EMOJI = [
'👍',
'❤',
'😆',
'😮',
'😢',
'😩',
];
export const sortEmoji = emojiReacts => (
emojiReacts.sortBy(emojiReact => -emojiReact.get('count'))
);
export const mergeEmoji = emojiReacts => (
emojiReacts // TODO: Merge similar emoji
);
export const mergeEmojiFavourites = (emojiReacts, favouritesCount, favourited) => {
if (!favouritesCount) return emojiReacts;
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍');
if (likeIndex > -1) {
const likeCount = emojiReacts.getIn([likeIndex, 'count']);
favourited = favourited || emojiReacts.getIn([likeIndex, 'me'], false);
return emojiReacts
.setIn([likeIndex, 'count'], likeCount + favouritesCount)
.setIn([likeIndex, 'me'], favourited);
} else {
return emojiReacts.push(ImmutableMap({ count: favouritesCount, me: favourited, name: '👍' }));
}
};
const hasMultiReactions = (emojiReacts, account) => (
emojiReacts.filter(
e => e.get('accounts').filter(
a => a.get('id') === account.get('id')
).count() > 0
).count() > 1
);
const inAccounts = (accounts, id) => (
accounts.filter(a => a.get('id') === id).count() > 0
);
export const oneEmojiPerAccount = (emojiReacts, me) => {
emojiReacts = emojiReacts.reverse();
return emojiReacts.reduce((acc, cur, idx) => {
const accounts = cur.get('accounts', ImmutableList())
.filter(a => !hasMultiReactions(acc, a));
return acc.set(idx, cur.merge({
accounts: accounts,
count: accounts.count(),
me: me ? inAccounts(accounts, me) : false,
}));
}, emojiReacts)
.filter(e => e.get('count') > 0)
.reverse();
};
export const filterEmoji = (emojiReacts, allowedEmoji=ALLOWED_EMOJI) => (
emojiReacts.filter(emojiReact => (
allowedEmoji.includes(emojiReact.get('name'))
)));
export const reduceEmoji = (emojiReacts, favouritesCount, favourited, allowedEmoji=ALLOWED_EMOJI) => (
filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites(
emojiReacts, favouritesCount, favourited
))), allowedEmoji));
export const getReactForStatus = status => {
return reduceEmoji(
status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()),
status.get('favourites_count'),
status.get('favourited')
).filter(e => e.get('me') === true)
.getIn([0, 'name']);
};
export const simulateEmojiReact = (emojiReacts, emoji) => {
const idx = emojiReacts.findIndex(e => e.get('name') === emoji);
if (idx > -1) {
const emojiReact = emojiReacts.get(idx);
return emojiReacts.set(idx, emojiReact.merge({
count: emojiReact.get('count') + 1,
me: true,
}));
} else {
return emojiReacts.push(ImmutableMap({
count: 1,
me: true,
name: emoji,
}));
}
};

@ -32,6 +32,8 @@
@import 'gabsocial/components/group-sidebar-panel';
@import 'gabsocial/components/sidebar-menu';
@import 'gabsocial/components/hotkeys-modal';
@import 'gabsocial/components/emoji-reacts';
@import 'gabsocial/components/detailed-status';
@import 'gabsocial/polls';
@import 'gabsocial/introduction';

@ -119,7 +119,7 @@
display: inline-block;
padding: 0;
color: $gab-secondary-text;
border: none;
border: 0;
background: transparent;
cursor: pointer;
transition: color 100ms ease-in;
@ -718,82 +718,6 @@
width: 23.15px;
}
.detailed-status__action-bar-dropdown {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.detailed-status {
background: lighten($ui-base-color, 4%);
padding: 14px 10px;
&--flex {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
.status__content,
.detailed-status__meta {
flex: 100%;
}
}
.status__content {
font-size: 19px;
line-height: 24px;
.emojione {
width: 24px;
height: 24px;
margin: -1px 0 0;
}
.status__content__spoiler-link {
line-height: 24px;
margin: -1px 0 0;
}
}
.video-player {
margin-top: 8px;
}
}
.detailed-status__meta {
margin-top: 15px;
color: $dark-text-color;
font-size: 14px;
line-height: 18px;
}
.detailed-status__action-bar {
background: lighten($ui-base-color, 4%);
border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
display: flex;
flex-direction: row;
padding: 10px 0;
}
.detailed-status__link {
color: $action-button-color;
cursor: pointer;
text-decoration: none;
font-size: 13px;
}
.detailed-status__favorites,
.detailed-status__reblogs {
display: inline-block;
font-weight: 500;
font-size: 12px;
margin-left: 6px;
}
.reply-indicator__content {
color: $gab-default-text-light;
font-size: 14px;

@ -0,0 +1,94 @@
.detailed-status__action-bar-dropdown {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.detailed-status {
background: lighten($ui-base-color, 4%);
padding: 14px 10px;
&--flex {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
.status__content,
.detailed-status__meta {
flex: 100%;
}
}
.status__content {
font-size: 19px;
line-height: 24px;
.emojione {
width: 24px;
height: 24px;
margin: -1px 0 0;
}
.status__content__spoiler-link {
line-height: 24px;
margin: -1px 0 0;
}
}
.video-player {
margin-top: 8px;
}
}
.detailed-status__meta {
margin-top: 15px;
color: $dark-text-color;
font-size: 14px;
line-height: 18px;
display: flex;
}
.detailed-status__action-bar {
background: lighten($ui-base-color, 4%);
border-top: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
display: flex;
flex-direction: row;
}
.detailed-status__link {
color: $action-button-color;
cursor: pointer;
text-decoration: none;
font-size: 13px;
}
.detailed-status__favorites,
.detailed-status__reblogs {
display: inline-block;
font-weight: 500;
font-size: 12px;
margin-left: 6px;
}
.detailed-status__button {
padding: 10px 0;
.icon-button {
display: inline-flex;
align-items: center;
.icon_button__text {
font-size: 14px;
padding-left: 3px;
transform: translateY(-1px);
}
}
}
.detailed-status__wrapper {
position: relative;
}

@ -0,0 +1,120 @@
.emoji-react {
display: inline-block;
transition: 0.1s;
&__emoji {
img {
width: 20px;
height: 20px;
filter: drop-shadow(2px 0 0 #fff); // FIXME: Use theme color
}
}
&__count {
display: none;
}
+ .emoji-react {
margin-right: -8px;
}
}
.emoji-reacts {
display: inline-flex;
flex-direction: row-reverse;
}
.status-interaction-bar {
margin-right: auto;
min-height: 20px;
}
.emoji-reacts-container {
display: inline-flex;
margin-right: auto;
&:hover {
.emoji-react {
margin: 0;
&__count {
display: inline;
}
}
.emoji-reacts__count {
display: none;
}
}
}
.emoji-reacts__count,
.emoji-react__count {
font-size: 12px;
font-weight: bold;
}
.emoji-react-selector {
position: absolute;
display: flex;
background-color: #fff;
padding: 5px 8px;
border-radius: 9999px;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.1);
opacity: 0;
pointer-events: none;
transition: 0.1s;
z-index: 999;
&--visible {
opacity: 1;
pointer-events: all;
}
&__emoji {
display: block;
padding: 0;
margin: 0;
border: 0;
background: transparent;
img {
width: 30px;
height: 30px;
padding: 3px;
transition: 0.1s;
}
&:hover {
img {
width: 36px;
height: 36px;
padding: 0;
}
}
}
}
.status__action-bar__counter--favourite {
position: relative;
@media(max-width: 455px) {
position: static;
}
}
.detailed-status__wrapper .emoji-react-selector {
bottom: 40px;
right: 10px;
}
.status .emoji-react-selector {
bottom: 100%;
left: -20px;
@media(max-width: 455px) {
bottom: 31px;
right: 10px;
left: auto;
}
}

@ -74,7 +74,7 @@
"html-webpack-harddisk-plugin": "^1.0.1",
"html-webpack-plugin": "^4.3.0",
"http-link-header": "^1.0.2",
"immutable": "^3.8.2",
"immutable": "^4.0.0-rc.12",
"imports-loader": "^0.8.0",
"intersection-observer": "^0.7.0",
"intl": "^1.2.5",

@ -5167,10 +5167,10 @@ ignore@^4.0.6:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
immutable@^3.8.2:
version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
immutable@^4.0.0-rc.12:
version "4.0.0-rc.12"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.12.tgz#ca59a7e4c19ae8d9bf74a97bdf0f6e2f2a5d0217"
integrity sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==
import-cwd@^2.0.0:
version "2.1.0"

Loading…
Cancel
Save