add frontend for emoji reactions

this is still pretty bare bones but hey, it works.
This commit is contained in:
fef 2022-11-24 17:30:52 +00:00 committed by neatchee
parent 445a7a8086
commit 531c5c9185
5 changed files with 334 additions and 1 deletions

View file

@ -41,6 +41,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const STATUS_REACTION_UPDATE = 'STATUS_REACTION_UPDATE';
export const STATUS_REACTION_ADD_REQUEST = 'STATUS_REACTION_ADD_REQUEST';
export const STATUS_REACTION_ADD_SUCCESS = 'STATUS_REACTION_ADD_SUCCESS';
export const STATUS_REACTION_ADD_FAIL = 'STATUS_REACTION_ADD_FAIL';
export const STATUS_REACTION_REMOVE_REQUEST = 'STATUS_REACTION_REMOVE_REQUEST';
export const STATUS_REACTION_REMOVE_SUCCESS = 'STATUS_REACTION_REMOVE_SUCCESS';
export const STATUS_REACTION_REMOVE_FAIL = 'STATUS_REACTION_REMOVE_FAIL';
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@ -391,4 +401,79 @@ export function unpinFail(status, error) {
status,
error,
};
}
};
export const statusAddReaction = (statusId, name) => (dispatch, getState) => {
const status = getState().get('statuses').get(statusId);
let alreadyAdded = false;
if (status) {
const reaction = status.get('reactions').find(x => x.get('name') === name);
if (reaction && reaction.get('me')) {
alreadyAdded = true;
}
}
if (!alreadyAdded) {
dispatch(statusAddReactionRequest(statusId, name, alreadyAdded));
}
api(getState).put(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => {
dispatch(statusAddReactionSuccess(statusId, name, alreadyAdded));
}).catch(err => {
if (!alreadyAdded) {
dispatch(statusAddReactionFail(statusId, name, err));
}
});
};
export const statusAddReactionRequest = (statusId, name) => ({
type: STATUS_REACTION_ADD_REQUEST,
id: statusId,
name,
skipLoading: true,
});
export const statusAddReactionSuccess = (statusId, name) => ({
type: STATUS_REACTION_ADD_SUCCESS,
id: statusId,
name,
skipLoading: true,
});
export const statusAddReactionFail = (statusId, name, error) => ({
type: STATUS_REACTION_ADD_FAIL,
id: statusId,
name,
error,
skipLoading: true,
});
export const statusRemoveReaction = (statusId, name) => (dispatch, getState) => {
dispatch(statusRemoveReactionRequest(statusId, name));
api(getState).delete(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => {
dispatch(statusRemoveReactionSuccess(statusId, name));
}).catch(err => {
dispatch(statusRemoveReactionFail(statusId, name, err));
});
};
export const statusRemoveReactionRequest = (statusId, name) => ({
type: STATUS_REACTION_REMOVE_REQUEST,
id: statusId,
name,
skipLoading: true,
});
export const statusRemoveReactionSuccess = (statusId, name) => ({
type: STATUS_REACTION_REMOVE_SUCCESS,
id: statusId,
name,
skipLoading: true,
});
export const statusRemoveReactionFail = (statusId, name) => ({
type: STATUS_REACTION_REMOVE_FAIL,
id: statusId,
name,
skipLoading: true,
});

View file

@ -6,6 +6,7 @@ import StatusHeader from './status_header';
import StatusIcons from './status_icons';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import StatusReactionsBar from './status_reactions_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
@ -77,6 +78,8 @@ class Status extends ImmutablePureComponent {
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onReactionAdd: PropTypes.func,
onReactionRemove: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
@ -104,6 +107,7 @@ class Status extends ImmutablePureComponent {
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
settings: ImmutablePropTypes.map.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
@ -822,6 +826,14 @@ class Status extends ImmutablePureComponent {
rewriteMentions={settings.get('rewrite_mentions')}
/>
<StatusReactionsBar
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
emojiMap={this.props.emojiMap}
/>
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
<StatusActionBar
status={status}

View file

@ -0,0 +1,177 @@
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { reduceMotion } from '../initial_state';
import spring from 'react-motion/lib/spring';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import classNames from 'classnames';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import Icon from './icon';
import React from 'react';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import AnimatedNumber from './animated_number';
import { assetHost } from '../utils/config';
import { autoPlayGif } from '../initial_state';
export default class StatusReactionsBar extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
handleEmojiPick = data => {
const { addReaction, statusId } = this.props;
addReaction(statusId, data.native.replace(/:/g, ''));
}
willEnter() {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave() {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render() {
const { reactions } = this.props;
const visibleReactions = reactions.filter(x => x.get('count') > 0);
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
statusId={this.props.statusId}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
);
}
}
class Reaction extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
};
state = {
hovered: false,
};
handleClick = () => {
const { reaction, statusId, addReaction, removeReaction } = this.props;
if (reaction.get('me')) {
removeReaction(statusId, reaction.get('name'));
} else {
addReaction(statusId, reaction.get('name'));
}
}
handleMouseEnter = () => this.setState({ hovered: true })
handleMouseLeave = () => this.setState({ hovered: false })
render() {
const { reaction } = this.props;
let shortCode = reaction.get('name');
if (unicodeMapping[shortCode]) {
shortCode = unicodeMapping[shortCode].shortCode;
}
return (
<button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
title={`:${shortCode}:`}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</button>
);
}
}
class Emoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.string.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
hovered: PropTypes.bool.isRequired,
};
render() {
const { emoji, emojiMap, hovered } = this.props;
if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[this.props.emoji];
const title = shortCode ? `:${shortCode}:` : '';
return (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else if (emojiMap.get(emoji)) {
const filename = (autoPlayGif || hovered)
? emojiMap.getIn([emoji, 'url'])
: emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
return null;
}
}
}

View file

@ -16,6 +16,8 @@ import {
unbookmark,
pin,
unpin,
statusAddReaction,
statusRemoveReaction,
} from 'flavours/glitch/actions/interactions';
import {
muteStatus,
@ -44,6 +46,10 @@ import { showAlertForError } from '../actions/alerts';
import AccountContainer from 'flavours/glitch/containers/account_container';
import Spoilers from '../components/spoilers';
import Icon from 'flavours/glitch/components/icon';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -87,6 +93,7 @@ const makeMapStateToProps = () => {
account: account || props.account,
settings: state.get('local_settings'),
prepend: prepend || props.prepend,
emojiMap: customEmojiMap(state),
pictureInPicture: getPictureInPicture(state, props),
};
};
@ -166,6 +173,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onReactionAdd (statusId, name) {
dispatch(statusAddReaction(statusId, name));
},
onReactionRemove (statusId, name) {
dispatch(statusRemoveReaction(statusId, name));
},
onEmbed (status) {
dispatch(openModal('EMBED', {
url: status.get('url'),

View file

@ -6,6 +6,11 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_REQUEST,
BOOKMARK_FAIL,
STATUS_REACTION_UPDATE,
STATUS_REACTION_ADD_FAIL,
STATUS_REACTION_REMOVE_FAIL,
STATUS_REACTION_ADD_REQUEST,
STATUS_REACTION_REMOVE_REQUEST,
} from 'flavours/glitch/actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -37,6 +42,37 @@ const deleteStatus = (state, id, references) => {
return state.delete(id);
};
const updateReaction = (state, id, name, updater) => state.update(
id,
status => status.update(
'reactions',
reactions => {
const index = reactions.findIndex(reaction => reaction.get('name') === name);
if (index > -1) {
return reactions.update(index, reaction => updater(reaction));
} else {
return reactions.push(updater(fromJS({ name, count: 0 })));
}
},
),
);
const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count));
const addReaction = (state, id, name) => updateReaction(
state,
id,
name,
x => x.set('me', true).update('count', n => n + 1),
);
const removeReaction = (state, id, name) => updateReaction(
state,
id,
name,
x => x.set('me', false).update('count', n => n - 1),
);
const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@ -63,6 +99,14 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
case STATUS_REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case STATUS_REACTION_ADD_REQUEST:
case STATUS_REACTION_REMOVE_FAIL:
return addReaction(state, action.id, action.name);
case STATUS_REACTION_REMOVE_REQUEST:
case STATUS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name);
case STATUS_MUTE_SUCCESS:
return state.setIn([action.id, 'muted'], true);
case STATUS_UNMUTE_SUCCESS: