Merge branch 'emojireacts' into 'master'
Emoji reactions See merge request soapbox-pub/soapbox-fe!18
This commit is contained in:
commit
963c68c642
20 changed files with 1057 additions and 111 deletions
177
app/gabsocial/actions/emoji_reacts.js
Normal file
177
app/gabsocial/actions/emoji_reacts.js
Normal file
|
@ -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,
|
||||
};
|
||||
};
|
36
app/gabsocial/components/emoji_selector.js
Normal file
36
app/gabsocial/components/emoji_selector.js
Normal file
|
@ -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:
|
||||
|
|
207
app/gabsocial/utils/__tests__/emoji_reacts-test.js
Normal file
207
app/gabsocial/utils/__tests__/emoji_reacts-test.js
Normal file
|
@ -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': '😯' },
|
||||
]));
|
||||
});
|
||||
});
|
102
app/gabsocial/utils/emoji_reacts.js
Normal file
102
app/gabsocial/utils/emoji_reacts.js
Normal file
|
@ -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;
|
||||
|
|
94
app/styles/gabsocial/components/detailed-status.scss
Normal file
94
app/styles/gabsocial/components/detailed-status.scss
Normal file
|
@ -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;
|
||||
}
|
120
app/styles/gabsocial/components/emoji-reacts.scss
Normal file
120
app/styles/gabsocial/components/emoji-reacts.scss
Normal file
|
@ -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…
Reference in a new issue