backport emoji reactions to vanilla flavour

main
anna 1 year ago
parent 4571ee9da6
commit 1ab64b9f2a
Signed by: fef
GPG Key ID: EC22E476DC2D3D84

@ -60,7 +60,7 @@ export default class StatusPrepend extends React.PureComponent {
return (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your post'
defaultMessage='{name} reacted to your status'
values={{ name: link }}
/>
);

@ -41,6 +41,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const REACTION_UPDATE = 'REACTION_UPDATE';
export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST';
export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS';
export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL';
export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST';
export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS';
export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL';
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@ -412,3 +422,78 @@ export function unpinFail(status, error) {
skipLoading: true,
};
};
export const addReaction = (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(addReactionRequest(statusId, name, alreadyAdded));
}
api(getState).put(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => {
dispatch(addReactionSuccess(statusId, name, alreadyAdded));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(statusId, name, err));
}
});
};
export const addReactionRequest = (statusId, name) => ({
type: REACTION_ADD_REQUEST,
id: statusId,
name,
skipLoading: true,
});
export const addReactionSuccess = (statusId, name) => ({
type: REACTION_ADD_SUCCESS,
id: statusId,
name,
skipLoading: true,
});
export const addReactionFail = (statusId, name, error) => ({
type: REACTION_ADD_FAIL,
id: statusId,
name,
error,
skipLoading: true,
});
export const removeReaction = (statusId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(statusId, name));
api(getState).delete(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => {
dispatch(removeReactionSuccess(statusId, name));
}).catch(err => {
dispatch(removeReactionFail(statusId, name, err));
});
};
export const removeReactionRequest = (statusId, name) => ({
type: REACTION_REMOVE_REQUEST,
id: statusId,
name,
skipLoading: true,
});
export const removeReactionSuccess = (statusId, name) => ({
type: REACTION_REMOVE_SUCCESS,
id: statusId,
name,
skipLoading: true,
});
export const removeReactionFail = (statusId, name) => ({
type: REACTION_REMOVE_FAIL,
id: statusId,
name,
skipLoading: true,
});

@ -127,6 +127,7 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',

@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import StatusReactions from './status_reactions';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
@ -21,6 +22,7 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { visibleReactions } from '../../flavours/glitch/initial_state';
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
@ -76,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,
@ -99,6 +103,7 @@ class Status extends ImmutablePureComponent {
cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
emojiMap: ImmutablePropTypes.map.isRequired,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
@ -537,6 +542,15 @@ class Status extends ImmutablePureComponent {
{media}
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
numVisible={visibleReactions}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
emojiMap={this.props.emojiMap}
/>
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>
</div>

@ -9,6 +9,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { maxReactions } from '../../flavours/glitch/initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -27,6 +29,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
@ -66,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReactionAdd: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -127,6 +131,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
handleEmojiPick = data => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''));
} else {
this.props.onInteractionModal('favourite', this.props.status);
}
}
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@ -230,6 +244,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onFilter();
}
nop = () => {}
render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn } = this.context.identity;
@ -349,11 +365,23 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
);
const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='status__action-bar-button'
onClick={this.nop} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{shareButton}

@ -0,0 +1,179 @@
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 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 StatusReactions extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
numVisible: PropTypes.number,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
willEnter() {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave() {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render() {
const { reactions, numVisible } = this.props;
let visibleReactions = reactions
.filter(x => x.get('count') > 0)
.sort((a, b) => b.get('count') - a.get('count'));
// numVisible might be NaN because it's pulled from local settings
// which doesn't do a whole lot of input validation, but that's okay
// because NaN >= 0 evaluates false.
// Still, this should be improved at some point.
if (numVisible >= 0) {
visibleReactions = visibleReactions.filter((_, i) => i < numVisible);
}
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}
/>
))}
</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;
}
}
}

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors';
import {
replyCompose,
mentionCompose,
@ -16,6 +16,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from '../actions/interactions';
import {
muteStatus,
@ -66,6 +68,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
pictureInPicture: getPictureInPicture(state, props),
emojiMap: makeCustomEmojiMap(state),
});
return mapStateToProps;
@ -129,6 +132,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onReactionAdd (statusId, name) {
dispatch(addReaction(statusId, name));
},
onReactionRemove (statusId, name) {
dispatch(removeReaction(statusId, name));
},
onEmbed (status) {
dispatch(openModal('EMBED', {
url: status.get('url'),

@ -319,6 +319,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -356,7 +357,7 @@ class EmojiPickerDropdown extends React.PureComponent {
}
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {

@ -6,6 +6,7 @@ import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button';
import SettingToggle from './setting_toggle';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
import PillBarButton from '../../../../flavours/glitch/features/notifications/components/pill_bar_button';
export default class ColumnSettings extends React.PureComponent {
@ -115,6 +116,17 @@ export default class ColumnSettings extends React.PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span>
<div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>

@ -6,6 +6,7 @@ import Icon from 'mastodon/components/icon';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent {
>
<Icon id='star' fixedWidth />
</button>
<button
className={selectedFilter === 'reaction' ? 'active' : ''}
onClick={this.onClick('reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='plus' fixedWidth />
</button>
<button
className={selectedFilter === 'reblog' ? 'active' : ''}
onClick={this.onClick('reblog')}

@ -15,6 +15,7 @@ import classNames from 'classnames';
const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
reaction: { id: 'notification.reaction', defaultMessage: '{name} reacted to your status' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
@ -213,6 +214,38 @@ class Notification extends ImmutablePureComponent {
);
}
renderReaction (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='plus' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.reaction' defaultMessage='{name} reacted to your status' 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>
);
}
renderReblog (notification, link) {
const { intl, unread } = this.props;
@ -414,6 +447,8 @@ class Notification extends ImmutablePureComponent {
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reaction':
return this.renderReaction(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'status':

@ -5,9 +5,10 @@ import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me } from '../../../initial_state';
import { me, maxReactions } from '../../../initial_state';
import classNames from 'classnames';
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -21,6 +22,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@ -61,6 +63,7 @@ class ActionBar extends React.PureComponent {
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReactionAdd: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@ -91,6 +94,10 @@ class ActionBar extends React.PureComponent {
this.props.onFavourite(this.props.status);
}
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''));
}
handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e);
}
@ -179,6 +186,8 @@ class ActionBar extends React.PureComponent {
navigator.clipboard.writeText(url);
}
nop = () => {} // hack for reaction add button
render () {
const { status, relationship, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -250,6 +259,17 @@ class ActionBar extends React.PureComponent {
}
}
const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reactButton = (
<IconButton
className='plus-icon'
onClick={this.nop} // EmojiPickerDropdown handles that
title={intl.formatMessage(messages.react)}
disabled={!canReact}
icon='plus'
/>
);
const shareButton = ('share' in navigator) && publicStatus && (
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
);
@ -279,6 +299,7 @@ class ActionBar extends React.PureComponent {
<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 className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' 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'><EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
{shareButton}

@ -17,6 +17,7 @@ import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import StatusReactions from '../../../components/status_reactions';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -48,6 +49,9 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
state = {
@ -275,6 +279,14 @@ class DetailedStatus extends ImmutablePureComponent {
{media}
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
emojiMap={this.props.emojiMap}
/>
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}\/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />

@ -20,6 +20,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from '../../actions/interactions';
import {
replyCompose,
@ -62,6 +64,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon';
import { Helmet } from 'react-helmet';
import { makeCustomEmojiMap } from '../../selectors';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -153,6 +156,7 @@ const makeMapStateToProps = () => {
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
emojiMap: makeCustomEmojiMap(state),
};
};
@ -250,6 +254,25 @@ class Status extends ImmutablePureComponent {
}
}
handleReactionAdd = (statusId, name) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
dispatch(addReaction(statusId, name));
} else {
dispatch(openModal('INTERACTION', {
type: 'reaction_add',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
}
}
handleReactionRemove = (statusId, name) => {
this.props.dispatch(removeReaction(statusId, name));
}
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@ -634,12 +657,15 @@ class Status extends ImmutablePureComponent {
status={status}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onReactionAdd={this.handleReactionAdd}
onReactionRemove={this.handleReactionRemove}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
emojiMap={this.props.emojiMap}
/>
<ActionBar
@ -647,6 +673,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReactionAdd={this.handleReactionAdd}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}

@ -113,8 +113,10 @@ export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const maxReactions = (initialState && initialState.max_reactions) || 1;
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const visibleReactions = getMeta('visible_reactions');
export const owner = getMeta('owner');
export const profile_directory = getMeta('profile_directory');
export const reduceMotion = getMeta('reduce_motion');

@ -388,6 +388,7 @@
"notification.admin.report": "{target} wurde von {name} gemeldet",
"notification.admin.sign_up": "{name} hat sich registriert",
"notification.favourite": "{name} hat deinen Beitrag favorisiert",
"notification.reaction": "{name} hat auf deinen Beitrag reagiert",
"notification.follow": "{name} folgt dir jetzt",
"notification.follow_request": "{name} möchte dir folgen",
"notification.mention": "{name} hat dich erwähnt",
@ -402,6 +403,7 @@
"notifications.column_settings.admin.sign_up": "Neue Anmeldungen:",
"notifications.column_settings.alert": "Desktop-Benachrichtigungen",
"notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.reaction": "Reaktionen:",
"notifications.column_settings.filter_bar.advanced": "Zeige alle Kategorien an",
"notifications.column_settings.filter_bar.category": "Schnellfilterleiste",
"notifications.column_settings.filter_bar.show_bar": "Filterleiste anzeigen",
@ -551,6 +553,7 @@
"status.edited_x_times": "{count, plural, one {{count} mal} other {{count} mal}} bearbeitet",
"status.embed": "Beitrag per iFrame einbetten",
"status.favourite": "Favorisieren",
"status.react": "Reagieren",
"status.filter": "Diesen Beitrag filtern",
"status.filtered": "Gefiltert",
"status.hide": "Beitrag verbergen",
@ -609,6 +612,7 @@
"timeline_hint.resources.statuses": "Ältere Beiträge",
"trends.counter_by_accounts": "{count, plural, one {{count} Person} other {{count} Personen}} {days, plural, one {am vergangenen Tag} other {in den vergangenen {days} Tagen}}",
"trends.trending_now": "In den Trends",
"tooltips.reactions": "Reaktionen",
"ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
"units.short.billion": "{count}B",
"units.short.million": "{count}M",

@ -393,6 +393,7 @@
"notification.admin.report": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.favourite": "{name} favourited your post",
"notification.reaction": "{name} reacted to your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
@ -407,6 +408,7 @@
"notifications.column_settings.admin.sign_up": "New sign-ups:",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.reaction": "Reactions:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
@ -556,6 +558,7 @@
"status.edited_x_times": "Edited {count, plural, one {{count} time} other {{count} times}}",
"status.embed": "Embed",
"status.favourite": "Favourite",
"status.react": "React",
"status.filter": "Filter this post",
"status.filtered": "Filtered",
"status.hide": "Hide toot",
@ -614,6 +617,7 @@
"timeline_hint.resources.statuses": "Older posts",
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
"trends.trending_now": "Trending now",
"tooltips.reactions": "Reactions",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"units.short.billion": "{count}B",
"units.short.million": "{count}M",

@ -388,6 +388,7 @@
"notification.admin.report": "{name} a signalé {target}",
"notification.admin.sign_up": "{name} s'est inscrit·e",
"notification.favourite": "{name} a ajouté le message à ses favoris",
"notification.reaction": "{name} a réagi·e à votre message",
"notification.follow": "{name} vous suit",
"notification.follow_request": "{name} a demandé à vous suivre",
"notification.mention": "{name} vous a mentionné·e:",
@ -402,6 +403,7 @@
"notifications.column_settings.admin.sign_up": "Nouvelles inscriptions :",
"notifications.column_settings.alert": "Notifications du navigateur",
"notifications.column_settings.favourite": "Favoris:",
"notifications.column_settings.reaction": "Réactions:",
"notifications.column_settings.filter_bar.advanced": "Afficher toutes les catégories",
"notifications.column_settings.filter_bar.category": "Barre de filtrage rapide",
"notifications.column_settings.filter_bar.show_bar": "Afficher la barre de filtre",
@ -551,6 +553,7 @@
"status.edited_x_times": "Edité {count, plural, one {{count} fois} other {{count} fois}}",
"status.embed": "Intégrer",
"status.favourite": "Ajouter aux favoris",
"status.react": "Réagir",
"status.filter": "Filtrer ce message",
"status.filtered": "Filtré",
"status.hide": "Cacher le pouet",
@ -609,6 +612,7 @@
"timeline_hint.resources.statuses": "Les messages plus anciens",
"trends.counter_by_accounts": "{count, plural, one {{counter} personne} other {{counter} personnes}} au cours {days, plural, one {des dernières 24h} other {des {days} derniers jours}}",
"trends.trending_now": "Tendance en ce moment",
"tooltips.reactions": "Réactions",
"ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",
"units.short.billion": "{count}Md",
"units.short.million": "{count}M",

@ -33,6 +33,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -56,6 +57,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,
@ -69,6 +71,7 @@ const initialState = ImmutableMap({
follow_request: false,
favourite: true,
reblog: true,
reaction: true,
mention: true,
poll: true,
status: true,

@ -6,6 +6,11 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_REQUEST,
BOOKMARK_FAIL,
REACTION_UPDATE,
REACTION_ADD_FAIL,
REACTION_REMOVE_FAIL,
REACTION_ADD_REQUEST,
REACTION_REMOVE_REQUEST,
} from '../actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -35,6 +40,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) {
@ -61,6 +97,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 REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case REACTION_ADD_REQUEST:
case REACTION_REMOVE_FAIL:
return addReaction(state, action.id, action.name);
case REACTION_REMOVE_REQUEST:
case 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:

@ -135,3 +135,11 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
});
export const makeCustomEmojiMap = createSelector(
[state => state.get('custom_emojis')],
items => items.reduce(
(map, emoji) => map.set(emoji.get('shortcode'), emoji),
ImmutableMap(),
),
);

@ -1106,6 +1106,10 @@
}
}
}
.reactions-bar--empty {
margin-top: 0;
}
}
.status__relative-time {
@ -1228,6 +1232,16 @@
align-items: center;
gap: 18px;
margin-top: 16px;
& > .emoji-picker-dropdown > .emoji-button {
padding: 0;
}
}
.status__action-bar-button {
.fa-plus {
padding-top: 1px;
}
}
.detailed-status__action-bar-dropdown {
@ -3986,6 +4000,10 @@ a.status-card.compact:hover {
text-align: center;
}
.detailed-status__button .emoji-button {
padding: 0;
}
.column-settings__outer {
background: lighten($ui-base-color, 8%);
padding: 15px;

Loading…
Cancel
Save