Emoji Reactions(hopefully)
This commit is contained in:
		
						commit
						fecf2fa9ce
					
				
					 79 changed files with 1471 additions and 30 deletions
				
			
		| 
						 | 
				
			
			@ -269,6 +269,9 @@ MAX_POLL_OPTIONS=5
 | 
			
		|||
# Maximum allowed poll option characters
 | 
			
		||||
MAX_POLL_OPTION_CHARS=100
 | 
			
		||||
 | 
			
		||||
# Maximum number of emoji reactions per toot and user (minimum 1)
 | 
			
		||||
MAX_REACTIONS=1
 | 
			
		||||
 | 
			
		||||
# Maximum image and video/audio upload sizes
 | 
			
		||||
# Units are in bytes
 | 
			
		||||
# 1048576 bytes equals 1 megabyte
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										25
									
								
								app/controllers/api/v1/statuses/reactions_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/controllers/api/v1/statuses/reactions_controller.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Api::V1::Statuses::ReactionsController < Api::BaseController
 | 
			
		||||
  include Authorization
 | 
			
		||||
 | 
			
		||||
  before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
 | 
			
		||||
  before_action :require_user!
 | 
			
		||||
  before_action :set_status
 | 
			
		||||
 | 
			
		||||
  def create
 | 
			
		||||
    ReactService.new.call(current_account, @status, params[:id])
 | 
			
		||||
    render_empty
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy
 | 
			
		||||
    UnreactService.new.call(current_account, @status, params[:id])
 | 
			
		||||
    render_empty
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_status
 | 
			
		||||
    @status = Status.find(params[:status_id])
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController
 | 
			
		|||
      :setting_use_pending_items,
 | 
			
		||||
      :setting_trends,
 | 
			
		||||
      :setting_crop_images,
 | 
			
		||||
      :setting_visible_reactions,
 | 
			
		||||
      :setting_always_send_emails,
 | 
			
		||||
      notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal),
 | 
			
		||||
      interactions: %i(must_be_follower must_be_following must_be_following_dm)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
| 
						 | 
				
			
			@ -392,3 +402,75 @@ export function unpinFail(status, error) {
 | 
			
		|||
    error,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addReaction = (statusId, name, url) => (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, url));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // encodeURIComponent is required for the Keycap Number Sign emoji, see:
 | 
			
		||||
  // <https://github.com/glitch-soc/mastodon/pull/1980#issuecomment-1345538932>
 | 
			
		||||
  api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => {
 | 
			
		||||
    dispatch(addReactionSuccess(statusId, name));
 | 
			
		||||
  }).catch(err => {
 | 
			
		||||
    if (!alreadyAdded) {
 | 
			
		||||
      dispatch(addReactionFail(statusId, name, err));
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addReactionRequest = (statusId, name, url) => ({
 | 
			
		||||
  type: REACTION_ADD_REQUEST,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
  url,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const addReactionSuccess = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_ADD_SUCCESS,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const addReactionFail = (statusId, name, error) => ({
 | 
			
		||||
  type: REACTION_ADD_FAIL,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
  error,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReaction = (statusId, name) => (dispatch, getState) => {
 | 
			
		||||
  dispatch(removeReactionRequest(statusId, name));
 | 
			
		||||
 | 
			
		||||
  api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(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,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReactionSuccess = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_REMOVE_SUCCESS,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReactionFail = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_REMOVE_FAIL,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => {
 | 
			
		|||
    'follow',
 | 
			
		||||
    'follow_request',
 | 
			
		||||
    'favourite',
 | 
			
		||||
    'reaction',
 | 
			
		||||
    'reblog',
 | 
			
		||||
    'mention',
 | 
			
		||||
    'poll',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 StatusReactions from './status_reactions';
 | 
			
		||||
import AttachmentList from './attachment_list';
 | 
			
		||||
import Card from '../features/status/components/card';
 | 
			
		||||
import { injectIntl, FormattedMessage } from 'react-intl';
 | 
			
		||||
| 
						 | 
				
			
			@ -16,7 +17,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
 | 
			
		||||
import PollContainer from 'flavours/glitch/containers/poll_container';
 | 
			
		||||
import { displayMedia } from 'flavours/glitch/initial_state';
 | 
			
		||||
import { displayMedia, visibleReactions } from 'flavours/glitch/initial_state';
 | 
			
		||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 | 
			
		||||
 | 
			
		||||
// We use the component (and not the container) since we do not want
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +62,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  static contextTypes = {
 | 
			
		||||
    router: PropTypes.object,
 | 
			
		||||
    identity: PropTypes.object,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			@ -723,6 +727,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    if (this.props.prepend && account) {
 | 
			
		||||
      const notifKind = {
 | 
			
		||||
        favourite: 'favourited',
 | 
			
		||||
        reaction: 'reacted',
 | 
			
		||||
        reblog: 'boosted',
 | 
			
		||||
        reblogged_by: 'boosted',
 | 
			
		||||
        status: 'posted',
 | 
			
		||||
| 
						 | 
				
			
			@ -801,6 +806,15 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
            rewriteMentions={settings.get('rewrite_mentions')}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <StatusReactions
 | 
			
		||||
            statusId={status.get('id')}
 | 
			
		||||
            reactions={status.get('reactions')}
 | 
			
		||||
            numVisible={visibleReactions}
 | 
			
		||||
            addReaction={this.props.onReactionAdd}
 | 
			
		||||
            removeReaction={this.props.onReactionRemove}
 | 
			
		||||
            canReact={this.context.identity.signedIn}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
 | 
			
		||||
            <StatusActionBar
 | 
			
		||||
              status={status}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,11 +5,12 @@ import IconButton from './icon_button';
 | 
			
		|||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 | 
			
		||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import { me } from 'flavours/glitch/initial_state';
 | 
			
		||||
import { me, maxReactions } from 'flavours/glitch/initial_state';
 | 
			
		||||
import RelativeTimestamp from './relative_timestamp';
 | 
			
		||||
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 | 
			
		||||
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +30,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' },
 | 
			
		||||
  open: { id: 'status.open', defaultMessage: 'Expand this status' },
 | 
			
		||||
  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +60,7 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
    status: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    onReply: PropTypes.func,
 | 
			
		||||
    onFavourite: PropTypes.func,
 | 
			
		||||
    onReactionAdd: PropTypes.func,
 | 
			
		||||
    onReblog: PropTypes.func,
 | 
			
		||||
    onQuote: PropTypes.func,
 | 
			
		||||
    onDelete: PropTypes.func,
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +119,10 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleEmojiPick = data => {
 | 
			
		||||
    this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReblogClick = e => {
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -208,8 +215,11 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
    this.props.onAddFilter(this.props.status);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleNoOp = () => {} // hack for reaction add button
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
 | 
			
		||||
    const anonymousAccess    = !me;
 | 
			
		||||
    const mutingConversation = status.get('muted');
 | 
			
		||||
| 
						 | 
				
			
			@ -311,6 +321,17 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
 | 
			
		||||
    const reactButton = (
 | 
			
		||||
      <IconButton
 | 
			
		||||
        className='status__action-bar-button'
 | 
			
		||||
        onClick={this.handleNoOp} // EmojiPickerDropdown handles that
 | 
			
		||||
        title={intl.formatMessage(messages.react)}
 | 
			
		||||
        disabled={!canReact}
 | 
			
		||||
        icon='plus'
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='status__action-bar'>
 | 
			
		||||
        <IconButton
 | 
			
		||||
| 
						 | 
				
			
			@ -325,6 +346,11 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
        <IconButton className='status__action-bar-button' disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
 | 
			
		||||
 | 
			
		||||
        <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} />
 | 
			
		||||
        {
 | 
			
		||||
          signedIn
 | 
			
		||||
            ? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
 | 
			
		||||
            : reactButton
 | 
			
		||||
        }
 | 
			
		||||
        {shareButton}
 | 
			
		||||
        <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -56,6 +56,14 @@ export default class StatusPrepend extends React.PureComponent {
 | 
			
		|||
          values={{ name : link }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    case 'reaction':
 | 
			
		||||
      return (
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
          id='notification.reaction'
 | 
			
		||||
          defaultMessage='{name} reacted to your status'
 | 
			
		||||
          values={{ name: link }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    case 'reblog':
 | 
			
		||||
      return (
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
| 
						 | 
				
			
			@ -110,6 +118,9 @@ export default class StatusPrepend extends React.PureComponent {
 | 
			
		|||
    case 'favourite':
 | 
			
		||||
      iconId = 'star';
 | 
			
		||||
      break;
 | 
			
		||||
    case 'reaction':
 | 
			
		||||
      iconId = 'plus';
 | 
			
		||||
      break;
 | 
			
		||||
    case 'featured':
 | 
			
		||||
      iconId = 'thumb-tack';
 | 
			
		||||
      break;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										170
									
								
								app/javascript/flavours/glitch/components/status_reactions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								app/javascript/flavours/glitch/components/status_reactions.js
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,170 @@
 | 
			
		|||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import { autoPlayGif, 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';
 | 
			
		||||
 | 
			
		||||
export default class StatusReactions extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    statusId: PropTypes.string.isRequired,
 | 
			
		||||
    reactions: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    numVisible: PropTypes.number,
 | 
			
		||||
    addReaction: PropTypes.func.isRequired,
 | 
			
		||||
    canReact: PropTypes.bool.isRequired,
 | 
			
		||||
    removeReaction: PropTypes.func.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'));
 | 
			
		||||
 | 
			
		||||
    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}
 | 
			
		||||
                canReact={this.props.canReact}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </TransitionMotion>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Reaction extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    statusId: PropTypes.string,
 | 
			
		||||
    reaction: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    addReaction: PropTypes.func.isRequired,
 | 
			
		||||
    removeReaction: PropTypes.func.isRequired,
 | 
			
		||||
    canReact: PropTypes.bool.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;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className={classNames('reactions-bar__item', { active: reaction.get('me') })}
 | 
			
		||||
        onClick={this.handleClick}
 | 
			
		||||
        onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
        onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
        disabled={!this.props.canReact}
 | 
			
		||||
        style={this.props.style}
 | 
			
		||||
      >
 | 
			
		||||
        <span className='reactions-bar__item__emoji'>
 | 
			
		||||
          <Emoji
 | 
			
		||||
            hovered={this.state.hovered}
 | 
			
		||||
            emoji={reaction.get('name')}
 | 
			
		||||
            url={reaction.get('url')}
 | 
			
		||||
            staticUrl={reaction.get('static_url')}
 | 
			
		||||
          />
 | 
			
		||||
        </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,
 | 
			
		||||
    hovered: PropTypes.bool.isRequired,
 | 
			
		||||
    url: PropTypes.string,
 | 
			
		||||
    staticUrl: PropTypes.string,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    const { emoji, hovered, url, staticUrl } = 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 {
 | 
			
		||||
      const filename = (autoPlayGif || hovered) ? url : staticUrl;
 | 
			
		||||
      const shortCode = `:${emoji}:`;
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <img
 | 
			
		||||
          draggable='false'
 | 
			
		||||
          className='emojione custom-emoji'
 | 
			
		||||
          alt={shortCode}
 | 
			
		||||
          title={shortCode}
 | 
			
		||||
          src={filename}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,5 @@
 | 
			
		|||
import { connect } from 'react-redux';
 | 
			
		||||
import Status from 'flavours/glitch/components/status';
 | 
			
		||||
import { List as ImmutableList } from 'immutable';
 | 
			
		||||
import { makeGetStatus } from 'flavours/glitch/selectors';
 | 
			
		||||
import {
 | 
			
		||||
  replyCompose,
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +16,8 @@ import {
 | 
			
		|||
  unbookmark,
 | 
			
		||||
  pin,
 | 
			
		||||
  unpin,
 | 
			
		||||
  addReaction,
 | 
			
		||||
  removeReaction,
 | 
			
		||||
} from 'flavours/glitch/actions/interactions';
 | 
			
		||||
import {
 | 
			
		||||
  muteStatus,
 | 
			
		||||
| 
						 | 
				
			
			@ -186,6 +187,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
 | 
			
		|||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onReactionAdd (statusId, name, url) {
 | 
			
		||||
    dispatch(addReaction(statusId, name, url));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onReactionRemove (statusId, name) {
 | 
			
		||||
    dispatch(removeReaction(statusId, name));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onEmbed (status) {
 | 
			
		||||
    dispatch(openModal('EMBED', {
 | 
			
		||||
      url: status.get('url'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -321,6 +321,7 @@ class EmojiPickerDropdown extends React.PureComponent {
 | 
			
		|||
    onSkinTone: PropTypes.func.isRequired,
 | 
			
		||||
    skinTone: PropTypes.number.isRequired,
 | 
			
		||||
    button: PropTypes.node,
 | 
			
		||||
    disabled: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -358,7 +359,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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -116,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'>
 | 
			
		||||
            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
 | 
			
		||||
            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
 | 
			
		||||
            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
 | 
			
		||||
            <PillBarButton 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 'flavours/glitch/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')}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -155,6 +155,28 @@ export default class Notification extends ImmutablePureComponent {
 | 
			
		|||
          unread={this.props.unread}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    case 'reaction':
 | 
			
		||||
      return (
 | 
			
		||||
        <StatusContainer
 | 
			
		||||
          containerId={notification.get('id')}
 | 
			
		||||
          hidden={hidden}
 | 
			
		||||
          id={notification.get('status')}
 | 
			
		||||
          account={notification.get('account')}
 | 
			
		||||
          prepend='reaction'
 | 
			
		||||
          muted
 | 
			
		||||
          notification={notification}
 | 
			
		||||
          onMoveDown={onMoveDown}
 | 
			
		||||
          onMoveUp={onMoveUp}
 | 
			
		||||
          onMention={onMention}
 | 
			
		||||
          getScrollPosition={getScrollPosition}
 | 
			
		||||
          updateScrollBottom={updateScrollBottom}
 | 
			
		||||
          cachedMediaWidth={this.props.cachedMediaWidth}
 | 
			
		||||
          cacheMediaWidth={this.props.cacheMediaWidth}
 | 
			
		||||
          onUnmount={this.props.onUnmount}
 | 
			
		||||
          withDismiss
 | 
			
		||||
          unread={this.props.unread}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    case 'reblog':
 | 
			
		||||
      return (
 | 
			
		||||
        <StatusContainer
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,10 +4,11 @@ import IconButton from 'flavours/glitch/components/icon_button';
 | 
			
		|||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 | 
			
		||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
			
		||||
import { me } from 'flavours/glitch/initial_state';
 | 
			
		||||
import { me, maxReactions } from 'flavours/glitch/initial_state';
 | 
			
		||||
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 | 
			
		||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +23,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}' },
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +54,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,
 | 
			
		||||
    onQuote: PropTypes.func.isRequired,
 | 
			
		||||
    onMute: PropTypes.func,
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +82,10 @@ class ActionBar extends React.PureComponent {
 | 
			
		|||
    this.props.onFavourite(this.props.status, e);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleEmojiPick = data => {
 | 
			
		||||
    this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleBookmarkClick = (e) => {
 | 
			
		||||
    this.props.onBookmark(this.props.status, e);
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -143,6 +150,8 @@ class ActionBar extends React.PureComponent {
 | 
			
		|||
    navigator.clipboard.writeText(url);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleNoOp = () => {} // hack for reaction add button
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { status, intl } = this.props;
 | 
			
		||||
    const { signedIn, permissions } = this.context.identity;
 | 
			
		||||
| 
						 | 
				
			
			@ -200,6 +209,17 @@ class ActionBar extends React.PureComponent {
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
 | 
			
		||||
    const reactButton = (
 | 
			
		||||
      <IconButton
 | 
			
		||||
        className='plus-icon'
 | 
			
		||||
        onClick={this.handleNoOp} // 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>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -223,6 +243,13 @@ class ActionBar extends React.PureComponent {
 | 
			
		|||
        <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='quote-right-icon' disabled={!publicStatus} title={intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></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'>
 | 
			
		||||
          {
 | 
			
		||||
            signedIn
 | 
			
		||||
              ? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
 | 
			
		||||
              : reactButton
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
        {shareButton}
 | 
			
		||||
        <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>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,12 +20,14 @@ import Icon from 'flavours/glitch/components/icon';
 | 
			
		|||
import AnimatedNumber from 'flavours/glitch/components/animated_number';
 | 
			
		||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 | 
			
		||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
 | 
			
		||||
import StatusReactions from 'flavours/glitch/components/status_reactions';
 | 
			
		||||
 | 
			
		||||
export default @injectIntl
 | 
			
		||||
class DetailedStatus extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static contextTypes = {
 | 
			
		||||
    router: PropTypes.object,
 | 
			
		||||
    identity: PropTypes.object,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +45,8 @@ class DetailedStatus extends ImmutablePureComponent {
 | 
			
		|||
    showMedia: PropTypes.bool,
 | 
			
		||||
    usingPiP: PropTypes.bool,
 | 
			
		||||
    onToggleMediaVisibility: PropTypes.func,
 | 
			
		||||
    onReactionAdd: PropTypes.func.isRequired,
 | 
			
		||||
    onReactionRemove: PropTypes.func.isRequired,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -319,6 +323,14 @@ class DetailedStatus extends ImmutablePureComponent {
 | 
			
		|||
            disabled
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <StatusReactions
 | 
			
		||||
            statusId={status.get('id')}
 | 
			
		||||
            reactions={status.get('reactions')}
 | 
			
		||||
            addReaction={this.props.onReactionAdd}
 | 
			
		||||
            removeReaction={this.props.onReactionRemove}
 | 
			
		||||
            canReact={this.context.identity.signedIn}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <div className='detailed-status__meta'>
 | 
			
		||||
            <a className='detailed-status__datetime' href={status.get('url')} 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 'flavours/glitch/actions/interactions';
 | 
			
		||||
import {
 | 
			
		||||
  replyCompose,
 | 
			
		||||
| 
						 | 
				
			
			@ -292,6 +294,19 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReactionAdd = (statusId, name, url) => {
 | 
			
		||||
    const { dispatch } = this.props;
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
 | 
			
		||||
    if (signedIn) {
 | 
			
		||||
      dispatch(addReaction(statusId, name, url));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReactionRemove = (statusId, name) => {
 | 
			
		||||
    this.props.dispatch(removeReaction(statusId, name));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handlePin = (status) => {
 | 
			
		||||
    if (status.get('pinned')) {
 | 
			
		||||
      this.props.dispatch(unpin(status));
 | 
			
		||||
| 
						 | 
				
			
			@ -692,6 +707,8 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
                  settings={settings}
 | 
			
		||||
                  onOpenVideo={this.handleOpenVideo}
 | 
			
		||||
                  onOpenMedia={this.handleOpenMedia}
 | 
			
		||||
                  onReactionAdd={this.handleReactionAdd}
 | 
			
		||||
                  onReactionRemove={this.handleReactionRemove}
 | 
			
		||||
                  expanded={isExpanded}
 | 
			
		||||
                  onToggleHidden={this.handleToggleHidden}
 | 
			
		||||
                  onTranslate={this.handleTranslate}
 | 
			
		||||
| 
						 | 
				
			
			@ -706,6 +723,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
                  status={status}
 | 
			
		||||
                  onReply={this.handleReplyClick}
 | 
			
		||||
                  onFavourite={this.handleFavouriteClick}
 | 
			
		||||
                  onReactionAdd={this.handleReactionAdd}
 | 
			
		||||
                  onReblog={this.handleReblogClick}
 | 
			
		||||
                  onBookmark={this.handleBookmarkClick}
 | 
			
		||||
                  onQuote={this.handleQuoteClick}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,6 +61,7 @@
 | 
			
		|||
 * @property {boolean} limited_federation_mode
 | 
			
		||||
 * @property {string} locale
 | 
			
		||||
 * @property {string | null} mascot
 | 
			
		||||
 * @property {number} max_reactions
 | 
			
		||||
 * @property {string=} me
 | 
			
		||||
 * @property {string=} moved_to_account_id
 | 
			
		||||
 * @property {string=} owner
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +80,7 @@
 | 
			
		|||
 * @property {boolean} use_blurhash
 | 
			
		||||
 * @property {boolean=} use_pending_items
 | 
			
		||||
 * @property {string} version
 | 
			
		||||
 * @property {number} visible_reactions
 | 
			
		||||
 * @property {boolean} translation_enabled
 | 
			
		||||
 * @property {object} local_settings
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -121,6 +123,7 @@ 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 owner = getMeta('owner');
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +141,7 @@ export const unfollowModal = getMeta('unfollow_modal');
 | 
			
		|||
export const useBlurhash = getMeta('use_blurhash');
 | 
			
		||||
export const usePendingItems = getMeta('use_pending_items');
 | 
			
		||||
export const version = getMeta('version');
 | 
			
		||||
export const visibleReactions = getMeta('visible_reactions');
 | 
			
		||||
export const translationEnabled = getMeta('translation_enabled');
 | 
			
		||||
export const languages = initialState?.languages;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -71,11 +71,13 @@
 | 
			
		|||
  "navigation_bar.keyboard_shortcuts": "Tastaturkürzel",
 | 
			
		||||
  "navigation_bar.misc": "Sonstiges",
 | 
			
		||||
  "notification.markForDeletion": "Zum Entfernen auswählen",
 | 
			
		||||
  "notification.reaction": "{name} hat auf deinen Beitrag reagiert",
 | 
			
		||||
  "notification_purge.btn_all": "Alle\nauswählen",
 | 
			
		||||
  "notification_purge.btn_apply": "Ausgewählte\nentfernen",
 | 
			
		||||
  "notification_purge.btn_invert": "Auswahl\numkehren",
 | 
			
		||||
  "notification_purge.btn_none": "Auswahl\naufheben",
 | 
			
		||||
  "notification_purge.start": "Benachrichtigungen-Aufräumen-Modus starten",
 | 
			
		||||
  "notifications.column_settings.reaction": "Reaktionen:",
 | 
			
		||||
  "notifications.marked_clear": "Ausgewählte Benachrichtigungen entfernen",
 | 
			
		||||
  "notifications.marked_clear_confirmation": "Möchtest du wirklich alle auswählten Benachrichtigungen für immer entfernen?",
 | 
			
		||||
  "onboarding.done": "Fertig",
 | 
			
		||||
| 
						 | 
				
			
			@ -185,8 +187,10 @@
 | 
			
		|||
  "status.in_reply_to": "Dieser Toot ist eine Antwort",
 | 
			
		||||
  "status.is_poll": "Dieser Toot ist eine Umfrage",
 | 
			
		||||
  "status.local_only": "Nur auf deiner Instanz sichtbar",
 | 
			
		||||
  "status.react": "Reagieren",
 | 
			
		||||
  "status.sensitive_toggle": "Zum Anzeigen klicken",
 | 
			
		||||
  "status.uncollapse": "Ausklappen",
 | 
			
		||||
  "tooltips.reactions": "Reaktionen",
 | 
			
		||||
  "web_app_crash.change_your_settings": "Deine {settings} ändern",
 | 
			
		||||
  "web_app_crash.content": "Du kannst folgende Dinge ausprobieren:",
 | 
			
		||||
  "web_app_crash.debug_info": "Debug-Informationen",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,6 +76,8 @@
 | 
			
		|||
  "notification_purge.btn_invert": "Inverser\nla sélection",
 | 
			
		||||
  "notification_purge.btn_none": "Annuler\nla sélection",
 | 
			
		||||
  "notification_purge.start": "Activer le mode de nettoyage des notifications",
 | 
			
		||||
  "notification.reaction": "{name} a réagi·e à votre message",
 | 
			
		||||
  "notifications.column_settings.reaction": "Réactions:",
 | 
			
		||||
  "notifications.marked_clear": "Effacer les notifications sélectionnées",
 | 
			
		||||
  "notifications.marked_clear_confirmation": "Voulez-vous vraiment effacer de manière permanente toutes les notifications sélectionnées ?",
 | 
			
		||||
  "onboarding.done": "Terminé",
 | 
			
		||||
| 
						 | 
				
			
			@ -185,8 +187,10 @@
 | 
			
		|||
  "status.in_reply_to": "Ce post est une réponse",
 | 
			
		||||
  "status.is_poll": "Ce post est un sondage",
 | 
			
		||||
  "status.local_only": "Visible uniquement depuis votre instance",
 | 
			
		||||
  "status.react": "Réagir",
 | 
			
		||||
  "status.sensitive_toggle": "Cliquer pour voir",
 | 
			
		||||
  "status.uncollapse": "Déplier",
 | 
			
		||||
  "tooltips.reactions": "Réactions",
 | 
			
		||||
  "web_app_crash.change_your_settings": "Changez vos {settings}",
 | 
			
		||||
  "web_app_crash.content": "Voici les différentes options qui s'offrent à vous :",
 | 
			
		||||
  "web_app_crash.debug_info": "Informations de débogage",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,6 +37,7 @@ const initialState = ImmutableMap({
 | 
			
		|||
      follow: false,
 | 
			
		||||
      follow_request: false,
 | 
			
		||||
      favourite: false,
 | 
			
		||||
      reaction: false,
 | 
			
		||||
      reblog: false,
 | 
			
		||||
      mention: false,
 | 
			
		||||
      poll: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +61,7 @@ const initialState = ImmutableMap({
 | 
			
		|||
      follow_request: false,
 | 
			
		||||
      favourite: true,
 | 
			
		||||
      reblog: true,
 | 
			
		||||
      reaction: true,
 | 
			
		||||
      mention: true,
 | 
			
		||||
      poll: true,
 | 
			
		||||
      status: true,
 | 
			
		||||
| 
						 | 
				
			
			@ -73,6 +75,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 'flavours/glitch/actions/interactions';
 | 
			
		||||
import {
 | 
			
		||||
  STATUS_MUTE_SUCCESS,
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +42,43 @@ 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));
 | 
			
		||||
 | 
			
		||||
// The url parameter is only used when adding a new custom emoji reaction
 | 
			
		||||
// (one that wasn't in the reactions list before) because we don't have its
 | 
			
		||||
// URL yet.  In all other cases, it's undefined.
 | 
			
		||||
const addReaction = (state, id, name, url) => updateReaction(
 | 
			
		||||
  state,
 | 
			
		||||
  id,
 | 
			
		||||
  name,
 | 
			
		||||
  x => x.set('me', true)
 | 
			
		||||
    .update('count', n => n + 1)
 | 
			
		||||
    .update('url', old => old ? old : url)
 | 
			
		||||
    .update('static_url', old => old ? old : url),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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 +105,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, action.url);
 | 
			
		||||
  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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -306,6 +306,10 @@
 | 
			
		|||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detailed-status__button .emoji-button {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.relationship-tag {
 | 
			
		||||
  color: $primary-text-color;
 | 
			
		||||
  margin-bottom: 4px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -440,6 +440,10 @@
 | 
			
		|||
  .notification__message {
 | 
			
		||||
    margin: -10px 0px 10px 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .reactions-bar--empty {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.notification-favourite {
 | 
			
		||||
| 
						 | 
				
			
			@ -584,6 +588,10 @@
 | 
			
		|||
  align-items: center;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
 | 
			
		||||
  & > .emoji-picker-dropdown > .emoji-button {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status__action-bar-button {
 | 
			
		||||
| 
						 | 
				
			
			@ -592,6 +600,10 @@
 | 
			
		|||
  &.icon-button--with-counter {
 | 
			
		||||
    margin-right: 14px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .fa-plus {
 | 
			
		||||
    padding-top: 1px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status__action-bar-dropdown {
 | 
			
		||||
| 
						 | 
				
			
			@ -658,6 +670,10 @@
 | 
			
		|||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  padding: 10px 0;
 | 
			
		||||
 | 
			
		||||
  .fa-plus {
 | 
			
		||||
    padding-top: 2px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detailed-status__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,75 @@ export function unpinFail(status, error) {
 | 
			
		|||
    skipLoading: true,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const addReaction = (statusId, name, url) => (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, url));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // encodeURIComponent is required for the Keycap Number Sign emoji, see:
 | 
			
		||||
  // <https://github.com/glitch-soc/mastodon/pull/1980#issuecomment-1345538932>
 | 
			
		||||
  api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => {
 | 
			
		||||
    dispatch(addReactionSuccess(statusId, name));
 | 
			
		||||
  }).catch(err => {
 | 
			
		||||
    if (!alreadyAdded) {
 | 
			
		||||
      dispatch(addReactionFail(statusId, name, err));
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const addReactionRequest = (statusId, name, url) => ({
 | 
			
		||||
  type: REACTION_ADD_REQUEST,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
  url,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const addReactionSuccess = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_ADD_SUCCESS,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const addReactionFail = (statusId, name, error) => ({
 | 
			
		||||
  type: REACTION_ADD_FAIL,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
  error,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReaction = (statusId, name) => (dispatch, getState) => {
 | 
			
		||||
  dispatch(removeReactionRequest(statusId, name));
 | 
			
		||||
 | 
			
		||||
  api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(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,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReactionSuccess = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_REMOVE_SUCCESS,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const removeReactionFail = (statusId, name) => ({
 | 
			
		||||
  type: REACTION_REMOVE_FAIL,
 | 
			
		||||
  id: statusId,
 | 
			
		||||
  name,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +16,7 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
 | 
			
		|||
import { HotKeys } from 'react-hotkeys';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import Icon from 'mastodon/components/icon';
 | 
			
		||||
import { displayMedia } from '../initial_state';
 | 
			
		||||
import { displayMedia, visibleReactions } from '../initial_state';
 | 
			
		||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
 | 
			
		||||
 | 
			
		||||
// We use the component (and not the container) since we do not want
 | 
			
		||||
| 
						 | 
				
			
			@ -64,6 +65,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  static contextTypes = {
 | 
			
		||||
    router: PropTypes.object,
 | 
			
		||||
    identity: PropTypes.object,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
              canReact={this.context.identity.signedIn}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,9 +6,10 @@ import IconButton from './icon_button';
 | 
			
		|||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
 | 
			
		||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
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 '../features/compose/containers/emoji_picker_dropdown_container';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +28,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 +68,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 +130,10 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleEmojiPick = data => {
 | 
			
		||||
      this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReblogClick = e => {
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -230,6 +237,8 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
    this.props.onFilter();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleNoOp = () => {} // hack for reaction add button
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
| 
						 | 
				
			
			@ -350,11 +359,27 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
			
		|||
      <IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
 | 
			
		||||
    const reactButton = (
 | 
			
		||||
      <IconButton
 | 
			
		||||
        className='status__action-bar-button'
 | 
			
		||||
        onClick={this.handleNoOp} // 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} />
 | 
			
		||||
        {
 | 
			
		||||
          signedIn
 | 
			
		||||
            ? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
 | 
			
		||||
            : reactButton
 | 
			
		||||
        }
 | 
			
		||||
        <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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										170
									
								
								app/javascript/mastodon/components/status_reactions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								app/javascript/mastodon/components/status_reactions.js
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,170 @@
 | 
			
		|||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import { autoPlayGif, 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';
 | 
			
		||||
 | 
			
		||||
export default class StatusReactions extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    statusId: PropTypes.string.isRequired,
 | 
			
		||||
    reactions: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    numVisible: PropTypes.number,
 | 
			
		||||
    addReaction: PropTypes.func.isRequired,
 | 
			
		||||
    canReact: PropTypes.bool.isRequired,
 | 
			
		||||
    removeReaction: PropTypes.func.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'));
 | 
			
		||||
 | 
			
		||||
    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}
 | 
			
		||||
                canReact={this.props.canReact}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </TransitionMotion>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Reaction extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    statusId: PropTypes.string,
 | 
			
		||||
    reaction: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    addReaction: PropTypes.func.isRequired,
 | 
			
		||||
    removeReaction: PropTypes.func.isRequired,
 | 
			
		||||
    canReact: PropTypes.bool.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;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <button
 | 
			
		||||
        className={classNames('reactions-bar__item', { active: reaction.get('me') })}
 | 
			
		||||
        onClick={this.handleClick}
 | 
			
		||||
        onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
        onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
        disabled={!this.props.canReact}
 | 
			
		||||
        style={this.props.style}
 | 
			
		||||
      >
 | 
			
		||||
        <span className='reactions-bar__item__emoji'>
 | 
			
		||||
          <Emoji
 | 
			
		||||
            hovered={this.state.hovered}
 | 
			
		||||
            emoji={reaction.get('name')}
 | 
			
		||||
            url={reaction.get('url')}
 | 
			
		||||
            staticUrl={reaction.get('static_url')}
 | 
			
		||||
          />
 | 
			
		||||
        </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,
 | 
			
		||||
    hovered: PropTypes.bool.isRequired,
 | 
			
		||||
    url: PropTypes.string,
 | 
			
		||||
    staticUrl: PropTypes.string,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    const { emoji, hovered, url, staticUrl } = 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 {
 | 
			
		||||
      const filename = (autoPlayGif || hovered) ? url : staticUrl;
 | 
			
		||||
      const shortCode = `:${emoji}:`;
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <img
 | 
			
		||||
          draggable='false'
 | 
			
		||||
          className='emojione custom-emoji'
 | 
			
		||||
          alt={shortCode}
 | 
			
		||||
          title={shortCode}
 | 
			
		||||
          src={filename}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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, url) {
 | 
			
		||||
    dispatch(addReaction(statusId, name, url));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -115,6 +115,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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleNoOp = () => {} // 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 = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
 | 
			
		||||
    const reactButton = (
 | 
			
		||||
      <IconButton
 | 
			
		||||
        className='plus-icon'
 | 
			
		||||
        onClick={this.handleNoOp} // 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,13 @@ 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'>
 | 
			
		||||
          {
 | 
			
		||||
            canReact
 | 
			
		||||
              ? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
 | 
			
		||||
              : reactButton
 | 
			
		||||
          }
 | 
			
		||||
        </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 'mastodon/components/status_reactions';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
 | 
			
		||||
| 
						 | 
				
			
			@ -30,6 +31,7 @@ class DetailedStatus extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  static contextTypes = {
 | 
			
		||||
    router: PropTypes.object,
 | 
			
		||||
    identity: PropTypes.object,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +50,8 @@ class DetailedStatus extends ImmutablePureComponent {
 | 
			
		|||
      available: PropTypes.bool,
 | 
			
		||||
    }),
 | 
			
		||||
    onToggleMediaVisibility: PropTypes.func,
 | 
			
		||||
    onReactionAdd: PropTypes.func.isRequired,
 | 
			
		||||
    onReactionRemove: PropTypes.func.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}
 | 
			
		||||
            canReact={this.context.identity.signedIn}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <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,
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +50,7 @@ import { initMuteModal } from '../../actions/mutes';
 | 
			
		|||
import { initBlockModal } from '../../actions/blocks';
 | 
			
		||||
import { initBoostModal } from '../../actions/boosts';
 | 
			
		||||
import { initReport } from '../../actions/reports';
 | 
			
		||||
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
 | 
			
		||||
import { makeCustomEmojiMap, makeGetStatus, makeGetPictureInPicture } from '../../selectors';
 | 
			
		||||
import ScrollContainer from 'mastodon/containers/scroll_container';
 | 
			
		||||
import ColumnBackButton from '../../components/column_back_button';
 | 
			
		||||
import ColumnHeader from '../../components/column_header';
 | 
			
		||||
| 
						 | 
				
			
			@ -153,6 +155,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),
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -254,6 +257,19 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReactionAdd = (statusId, name, url) => {
 | 
			
		||||
    const { dispatch } = this.props;
 | 
			
		||||
    const { signedIn } = this.context.identity;
 | 
			
		||||
 | 
			
		||||
    if (signedIn) {
 | 
			
		||||
      dispatch(addReaction(statusId, name, url));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleReactionRemove = (statusId, name) => {
 | 
			
		||||
    this.props.dispatch(removeReaction(statusId, name));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handlePin = (status) => {
 | 
			
		||||
    if (status.get('pinned')) {
 | 
			
		||||
      this.props.dispatch(unpin(status));
 | 
			
		||||
| 
						 | 
				
			
			@ -638,12 +654,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
 | 
			
		||||
| 
						 | 
				
			
			@ -651,6 +670,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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -61,6 +61,7 @@
 | 
			
		|||
 * @property {boolean} limited_federation_mode
 | 
			
		||||
 * @property {string} locale
 | 
			
		||||
 * @property {string | null} mascot
 | 
			
		||||
 * @property {number} max_reactions
 | 
			
		||||
 * @property {string=} me
 | 
			
		||||
 * @property {string=} moved_to_account_id
 | 
			
		||||
 * @property {string=} owner
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +80,7 @@
 | 
			
		|||
 * @property {boolean} use_blurhash
 | 
			
		||||
 * @property {boolean=} use_pending_items
 | 
			
		||||
 * @property {string} version
 | 
			
		||||
 * @property {number} visible_reactions
 | 
			
		||||
 * @property {boolean} translation_enabled
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -113,6 +115,7 @@ 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 owner = getMeta('owner');
 | 
			
		||||
| 
						 | 
				
			
			@ -130,6 +133,7 @@ export const unfollowModal = getMeta('unfollow_modal');
 | 
			
		|||
export const useBlurhash = getMeta('use_blurhash');
 | 
			
		||||
export const usePendingItems = getMeta('use_pending_items');
 | 
			
		||||
export const version = getMeta('version');
 | 
			
		||||
export const visibleReactions = getMeta('visible_reactions');
 | 
			
		||||
export const translationEnabled = getMeta('translation_enabled');
 | 
			
		||||
export const languages = initialState?.languages;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -393,6 +393,7 @@
 | 
			
		|||
  "notification.admin.report": "{target} wurde von {name} gemeldet",
 | 
			
		||||
  "notification.admin.sign_up": "{name} registrierte sich",
 | 
			
		||||
  "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} erwähnte dich",
 | 
			
		||||
| 
						 | 
				
			
			@ -407,6 +408,7 @@
 | 
			
		|||
  "notifications.column_settings.admin.sign_up": "Neue Registrierungen:",
 | 
			
		||||
  "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
 | 
			
		||||
  "notifications.column_settings.favourite": "Favorisierungen:",
 | 
			
		||||
  "notifications.column_settings.reaction": "Reaktionen:",
 | 
			
		||||
  "notifications.column_settings.filter_bar.advanced": "Erweiterte Filterleiste aktivieren",
 | 
			
		||||
  "notifications.column_settings.filter_bar.category": "Filterleiste:",
 | 
			
		||||
  "notifications.column_settings.filter_bar.show_bar": "Filterleiste anzeigen",
 | 
			
		||||
| 
						 | 
				
			
			@ -556,6 +558,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": "Beitrag filtern",
 | 
			
		||||
  "status.filtered": "Gefiltert",
 | 
			
		||||
  "status.hide": "Beitrag verbergen",
 | 
			
		||||
| 
						 | 
				
			
			@ -614,6 +617,7 @@
 | 
			
		|||
  "timeline_hint.resources.statuses": "Ältere Beiträge",
 | 
			
		||||
  "trends.counter_by_accounts": "{count, plural, one {{counter} Profil} other {{counter} Profile}} {days, plural, one {seit gestern} other {in {days} Tagen}}",
 | 
			
		||||
  "trends.trending_now": "Aktuelle Trends",
 | 
			
		||||
  "tooltips.reactions": "Reaktionen",
 | 
			
		||||
  "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
 | 
			
		||||
  "units.short.billion": "{count} Mrd",
 | 
			
		||||
  "units.short.million": "{count} Mio",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -398,6 +398,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",
 | 
			
		||||
| 
						 | 
				
			
			@ -412,6 +413,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",
 | 
			
		||||
| 
						 | 
				
			
			@ -561,6 +563,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",
 | 
			
		||||
| 
						 | 
				
			
			@ -619,6 +622,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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,43 @@ 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));
 | 
			
		||||
 | 
			
		||||
// The url parameter is only used when adding a new custom emoji reaction
 | 
			
		||||
// (one that wasn't in the reactions list before) because we don't have its
 | 
			
		||||
// URL yet.  In all other cases, it's undefined.
 | 
			
		||||
const addReaction = (state, id, name, url) => updateReaction(
 | 
			
		||||
  state,
 | 
			
		||||
  id,
 | 
			
		||||
  name,
 | 
			
		||||
  x => x.set('me', true)
 | 
			
		||||
    .update('count', n => n + 1)
 | 
			
		||||
    .update('url', old => old ? old : url)
 | 
			
		||||
    .update('static_url', old => old ? old : url),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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 +103,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, action.url);
 | 
			
		||||
  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(),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1130,6 +1130,10 @@
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .reactions-bar--empty {
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status__relative-time {
 | 
			
		||||
| 
						 | 
				
			
			@ -1252,6 +1256,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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -4019,6 +4033,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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,6 +39,8 @@ class ActivityPub::Activity
 | 
			
		|||
        ActivityPub::Activity::Follow
 | 
			
		||||
      when 'Like'
 | 
			
		||||
        ActivityPub::Activity::Like
 | 
			
		||||
      when 'EmojiReact'
 | 
			
		||||
        ActivityPub::Activity::EmojiReact
 | 
			
		||||
      when 'Block'
 | 
			
		||||
        ActivityPub::Activity::Block
 | 
			
		||||
      when 'Update'
 | 
			
		||||
| 
						 | 
				
			
			@ -174,4 +176,33 @@ class ActivityPub::Activity
 | 
			
		|||
    Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_actor] && "via #{@options[:relayed_through_actor].uri}"}")
 | 
			
		||||
    nil
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Ensure all emojis declared in the activity's tags are
 | 
			
		||||
  # present in the database and downloaded to the local cache.
 | 
			
		||||
  # Required by EmojiReact and Like for emoji reactions.
 | 
			
		||||
  def process_emoji_tags(tags)
 | 
			
		||||
    as_array(tags).each do |tag|
 | 
			
		||||
      process_single_emoji tag if tag['type'] == 'Emoji'
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_single_emoji(tag)
 | 
			
		||||
    custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
 | 
			
		||||
    return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
 | 
			
		||||
 | 
			
		||||
    emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
 | 
			
		||||
    return unless emoji.nil? ||
 | 
			
		||||
                  custom_emoji_parser.image_remote_url != emoji.image_remote_url ||
 | 
			
		||||
                  (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      emoji ||= CustomEmoji.new(domain: @account.domain,
 | 
			
		||||
                                shortcode: custom_emoji_parser.shortcode,
 | 
			
		||||
                                uri: custom_emoji_parser.uri)
 | 
			
		||||
      emoji.image_remote_url = custom_emoji_parser.image_remote_url
 | 
			
		||||
      emoji.save
 | 
			
		||||
    rescue Seahorse::Client::NetworkingError => e
 | 
			
		||||
      Rails.logger.warn "Error fetching emoji: #{e}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										25
									
								
								app/lib/activitypub/activity/emoji_react.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/lib/activitypub/activity/emoji_react.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class ActivityPub::Activity::EmojiReact < ActivityPub::Activity
 | 
			
		||||
  def perform
 | 
			
		||||
    original_status = status_from_uri(object_uri)
 | 
			
		||||
    name = @json['content']
 | 
			
		||||
    return if original_status.nil? ||
 | 
			
		||||
              !original_status.account.local? ||
 | 
			
		||||
              delete_arrived_first?(@json['id']) ||
 | 
			
		||||
              @account.reacted?(original_status, name)
 | 
			
		||||
 | 
			
		||||
    custom_emoji = nil
 | 
			
		||||
    if name =~ /^:.*:$/
 | 
			
		||||
      process_emoji_tags(@json['tag'])
 | 
			
		||||
 | 
			
		||||
      name.delete! ':'
 | 
			
		||||
      custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
 | 
			
		||||
      return if custom_emoji.nil?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
 | 
			
		||||
 | 
			
		||||
    LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -3,12 +3,36 @@
 | 
			
		|||
class ActivityPub::Activity::Like < ActivityPub::Activity
 | 
			
		||||
  def perform
 | 
			
		||||
    original_status = status_from_uri(object_uri)
 | 
			
		||||
    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id'])
 | 
			
		||||
 | 
			
		||||
    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
 | 
			
		||||
    return if maybe_process_misskey_reaction(original_status)
 | 
			
		||||
 | 
			
		||||
    return if @account.favourited?(original_status)
 | 
			
		||||
 | 
			
		||||
    favourite = original_status.favourites.create!(account: @account)
 | 
			
		||||
 | 
			
		||||
    LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite')
 | 
			
		||||
    Trends.statuses.register(original_status)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Misskey delivers reactions as likes with the emoji in _misskey_reaction
 | 
			
		||||
  # see https://misskey-hub.net/ns.html#misskey-reaction for details
 | 
			
		||||
  def maybe_process_misskey_reaction(original_status)
 | 
			
		||||
    name = @json['_misskey_reaction']
 | 
			
		||||
    return false if name.nil?
 | 
			
		||||
 | 
			
		||||
    custom_emoji = nil
 | 
			
		||||
    if name =~ /^:.*:$/
 | 
			
		||||
      process_emoji_tags(@json['tag'])
 | 
			
		||||
 | 
			
		||||
      name.delete! ':'
 | 
			
		||||
      custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
 | 
			
		||||
      return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like
 | 
			
		||||
    end
 | 
			
		||||
    return true if @account.reacted?(original_status, name)
 | 
			
		||||
 | 
			
		||||
    reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
 | 
			
		||||
    LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
 | 
			
		|||
      undo_follow
 | 
			
		||||
    when 'Like'
 | 
			
		||||
      undo_like
 | 
			
		||||
    when 'EmojiReact'
 | 
			
		||||
      undo_emoji_react
 | 
			
		||||
    when 'Block'
 | 
			
		||||
      undo_block
 | 
			
		||||
    when nil
 | 
			
		||||
| 
						 | 
				
			
			@ -113,6 +115,22 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def undo_emoji_react
 | 
			
		||||
    name = @object['content']
 | 
			
		||||
    return if name.nil?
 | 
			
		||||
 | 
			
		||||
    status = status_from_uri(target_uri)
 | 
			
		||||
 | 
			
		||||
    return if status.nil? || !status.account.local?
 | 
			
		||||
 | 
			
		||||
    if @account.reacted?(status, name.delete(':'))
 | 
			
		||||
      reaction = status.status_reactions.where(account: @account, name: name).first
 | 
			
		||||
      reaction&.destroy
 | 
			
		||||
    else
 | 
			
		||||
      delete_later!(object_uri)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def undo_block
 | 
			
		||||
    target_account = account_from_uri(target_uri)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,6 +43,7 @@ class UserSettingsDecorator
 | 
			
		|||
    user.settings['use_pending_items']   = use_pending_items_preference if change?('setting_use_pending_items')
 | 
			
		||||
    user.settings['trends']              = trends_preference if change?('setting_trends')
 | 
			
		||||
    user.settings['crop_images']         = crop_images_preference if change?('setting_crop_images')
 | 
			
		||||
    user.settings['visible_reactions']   = visible_reactions_preference if change?('setting_visible_reactions')
 | 
			
		||||
    user.settings['always_send_emails']  = always_send_emails_preference if change?('setting_always_send_emails')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +159,10 @@ class UserSettingsDecorator
 | 
			
		|||
    boolean_cast_setting 'setting_crop_images'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def visible_reactions_preference
 | 
			
		||||
    integer_cast_setting('setting_visible_reactions', 0)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def always_send_emails_preference
 | 
			
		||||
    boolean_cast_setting 'setting_always_send_emails'
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -166,6 +171,15 @@ class UserSettingsDecorator
 | 
			
		|||
    ActiveModel::Type::Boolean.new.cast(settings[key])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def integer_cast_setting(key, min = nil, max = nil)
 | 
			
		||||
    i = ActiveModel::Type::Integer.new.cast(settings[key])
 | 
			
		||||
    # the cast above doesn't return a number if passed the string "e"
 | 
			
		||||
    i = 0 unless i.is_a? Numeric
 | 
			
		||||
    return min if !min.nil? && i < min
 | 
			
		||||
    return max if !max.nil? && i > max
 | 
			
		||||
    i
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def coerced_settings(key)
 | 
			
		||||
    coerce_values settings.fetch(key, {})
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -239,6 +239,10 @@ module AccountInteractions
 | 
			
		|||
    status.proper.favourites.where(account: self).exists?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reacted?(status, name)
 | 
			
		||||
    status.proper.status_reactions.where(account: self, name: name).exists?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def bookmarked?(status)
 | 
			
		||||
    status.proper.bookmarks.where(account: self).exists?
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,6 +24,7 @@ class Notification < ApplicationRecord
 | 
			
		|||
    'Follow'         => :follow,
 | 
			
		||||
    'FollowRequest'  => :follow_request,
 | 
			
		||||
    'Favourite'      => :favourite,
 | 
			
		||||
    'StatusReaction' => :reaction,
 | 
			
		||||
    'Poll'           => :poll,
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -34,6 +35,7 @@ class Notification < ApplicationRecord
 | 
			
		|||
    follow
 | 
			
		||||
    follow_request
 | 
			
		||||
    favourite
 | 
			
		||||
    reaction
 | 
			
		||||
    poll
 | 
			
		||||
    update
 | 
			
		||||
    admin.sign_up
 | 
			
		||||
| 
						 | 
				
			
			@ -45,6 +47,7 @@ class Notification < ApplicationRecord
 | 
			
		|||
    reblog: [status: :reblog],
 | 
			
		||||
    mention: [mention: :status],
 | 
			
		||||
    favourite: [favourite: :status],
 | 
			
		||||
    reaction: [status_reaction: :status],
 | 
			
		||||
    poll: [poll: :status],
 | 
			
		||||
    update: :status,
 | 
			
		||||
    'admin.report': [report: :target_account],
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +64,7 @@ class Notification < ApplicationRecord
 | 
			
		|||
  belongs_to :favourite,       foreign_key: 'activity_id', optional: true
 | 
			
		||||
  belongs_to :poll,            foreign_key: 'activity_id', optional: true
 | 
			
		||||
  belongs_to :report,          foreign_key: 'activity_id', optional: true
 | 
			
		||||
  belongs_to :status_reaction, foreign_key: 'activity_id', optional: true
 | 
			
		||||
 | 
			
		||||
  validates :type, inclusion: { in: TYPES }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +82,8 @@ class Notification < ApplicationRecord
 | 
			
		|||
      status&.reblog
 | 
			
		||||
    when :favourite
 | 
			
		||||
      favourite&.status
 | 
			
		||||
    when :reaction
 | 
			
		||||
      status_reaction&.status
 | 
			
		||||
    when :mention
 | 
			
		||||
      mention&.status
 | 
			
		||||
    when :poll
 | 
			
		||||
| 
						 | 
				
			
			@ -129,6 +135,8 @@ class Notification < ApplicationRecord
 | 
			
		|||
          notification.status.reblog = cached_status
 | 
			
		||||
        when :favourite
 | 
			
		||||
          notification.favourite.status = cached_status
 | 
			
		||||
        when :reaction
 | 
			
		||||
          notification.reaction.status = cached_status
 | 
			
		||||
        when :mention
 | 
			
		||||
          notification.mention.status = cached_status
 | 
			
		||||
        when :poll
 | 
			
		||||
| 
						 | 
				
			
			@ -140,6 +148,8 @@ class Notification < ApplicationRecord
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  alias reaction status_reaction
 | 
			
		||||
 | 
			
		||||
  after_initialize :set_from_account
 | 
			
		||||
  before_validation :set_from_account
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +159,7 @@ class Notification < ApplicationRecord
 | 
			
		|||
    return unless new_record?
 | 
			
		||||
 | 
			
		||||
    case activity_type
 | 
			
		||||
    when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
 | 
			
		||||
    when 'Status', 'Follow', 'Favourite', 'StatusReaction', 'FollowRequest', 'Poll', 'Report'
 | 
			
		||||
      self.from_account_id = activity&.account_id
 | 
			
		||||
    when 'Mention'
 | 
			
		||||
      self.from_account_id = activity&.status&.account_id
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,6 +74,7 @@ class Status < ApplicationRecord
 | 
			
		|||
  has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
 | 
			
		||||
  has_many :media_attachments, dependent: :nullify
 | 
			
		||||
  has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
 | 
			
		||||
  has_many :status_reactions, inverse_of: :status, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
  has_and_belongs_to_many :tags
 | 
			
		||||
  has_and_belongs_to_many :preview_cards
 | 
			
		||||
| 
						 | 
				
			
			@ -287,6 +288,21 @@ class Status < ApplicationRecord
 | 
			
		|||
    @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reactions(account = nil)
 | 
			
		||||
    records = begin
 | 
			
		||||
      scope = status_reactions.group(:status_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
 | 
			
		||||
 | 
			
		||||
      if account.nil?
 | 
			
		||||
        scope.select('name, custom_emoji_id, count(*) as count, false as me')
 | 
			
		||||
      else
 | 
			
		||||
        scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from status_reactions r where r.account_id = #{account.id} and r.status_id = status_reactions.status_id and r.name = status_reactions.name) as me")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
 | 
			
		||||
    records
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def ordered_media_attachments
 | 
			
		||||
    if ordered_media_attachment_ids.nil?
 | 
			
		||||
      media_attachments
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										31
									
								
								app/models/status_reaction.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/models/status_reaction.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: status_reactions
 | 
			
		||||
#
 | 
			
		||||
#  id              :bigint(8)        not null, primary key
 | 
			
		||||
#  account_id      :bigint(8)        not null
 | 
			
		||||
#  status_id       :bigint(8)        not null
 | 
			
		||||
#  name            :string           default(""), not null
 | 
			
		||||
#  custom_emoji_id :bigint(8)
 | 
			
		||||
#  created_at      :datetime         not null
 | 
			
		||||
#  updated_at      :datetime         not null
 | 
			
		||||
#
 | 
			
		||||
class StatusReaction < ApplicationRecord
 | 
			
		||||
  belongs_to :account
 | 
			
		||||
  belongs_to :status, inverse_of: :status_reactions
 | 
			
		||||
  belongs_to :custom_emoji, optional: true
 | 
			
		||||
 | 
			
		||||
  has_one :notification, as: :activity, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
  validates :name, presence: true
 | 
			
		||||
  validates_with StatusReactionValidator
 | 
			
		||||
 | 
			
		||||
  before_validation :set_custom_emoji
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_custom_emoji
 | 
			
		||||
    self.custom_emoji = CustomEmoji.find_by(shortcode: name, domain: account.domain) if name.blank?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -134,7 +134,7 @@ class User < ApplicationRecord
 | 
			
		|||
  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
 | 
			
		||||
           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_followers_count,
 | 
			
		||||
           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
 | 
			
		||||
           :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
 | 
			
		||||
           :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :visible_reactions,
 | 
			
		||||
           :disable_swiping, :always_send_emails, :default_content_type, :system_emoji_font,
 | 
			
		||||
           to: :settings, prefix: :setting, allow_nil: false
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										39
									
								
								app/serializers/activitypub/emoji_reaction_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/serializers/activitypub/emoji_reaction_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
 | 
			
		||||
  attributes :id, :type, :actor, :content
 | 
			
		||||
  attribute :virtual_object, key: :object
 | 
			
		||||
  attribute :custom_emoji, key: :tag, unless: -> { object.custom_emoji.nil? }
 | 
			
		||||
 | 
			
		||||
  def id
 | 
			
		||||
    [ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id].join
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def type
 | 
			
		||||
    'EmojiReact'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def actor
 | 
			
		||||
    ActivityPub::TagManager.instance.uri_for(object.account)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def virtual_object
 | 
			
		||||
    ActivityPub::TagManager.instance.uri_for(object.status)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def content
 | 
			
		||||
    if object.custom_emoji.nil?
 | 
			
		||||
      object.name
 | 
			
		||||
    else
 | 
			
		||||
      ":#{object.name}:"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  alias reaction content
 | 
			
		||||
 | 
			
		||||
  # Akkoma (and possibly others) expect `tag` to be an array, so we can't just
 | 
			
		||||
  # use the has_one shorthand because we need to wrap it into an array manually
 | 
			
		||||
  def custom_emoji
 | 
			
		||||
    [ActivityPub::EmojiSerializer.new(object.custom_emoji).serializable_hash]
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer
 | 
			
		||||
  attributes :id, :type, :actor
 | 
			
		||||
 | 
			
		||||
  has_one :object, serializer: ActivityPub::EmojiReactionSerializer
 | 
			
		||||
 | 
			
		||||
  def id
 | 
			
		||||
    [ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id, '/undo'].join
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def type
 | 
			
		||||
    'Undo'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def actor
 | 
			
		||||
    ActivityPub::TagManager.instance.uri_for(object.account)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
 | 
			
		|||
  attributes :meta, :compose, :accounts,
 | 
			
		||||
             :media_attachments, :settings,
 | 
			
		||||
             :max_toot_chars, :poll_limits,
 | 
			
		||||
             :languages
 | 
			
		||||
             :languages, :max_reactions
 | 
			
		||||
 | 
			
		||||
  has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  has_one :role, serializer: REST::RoleSerializer
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,10 @@ class InitialStateSerializer < ActiveModel::Serializer
 | 
			
		|||
    StatusLengthValidator::MAX_CHARS
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def max_reactions
 | 
			
		||||
    StatusReactionValidator::LIMIT
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def poll_limits
 | 
			
		||||
    {
 | 
			
		||||
      max_options: PollValidator::MAX_OPTIONS,
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +70,7 @@ class InitialStateSerializer < ActiveModel::Serializer
 | 
			
		|||
      store[:default_content_type] = object.current_account.user.setting_default_content_type
 | 
			
		||||
      store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font
 | 
			
		||||
      store[:crop_images]       = object.current_account.user.setting_crop_images
 | 
			
		||||
      store[:visible_reactions] = object.current_account.user.setting_visible_reactions
 | 
			
		||||
    else
 | 
			
		||||
      store[:auto_play_gif] = Setting.auto_play_gif
 | 
			
		||||
      store[:display_media] = Setting.display_media
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,6 +76,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
 | 
			
		|||
      translation: {
 | 
			
		||||
        enabled: TranslationService.configured?,
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      reactions: {
 | 
			
		||||
        max_reactions: StatusReactionValidator::LIMIT,
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def status_type?
 | 
			
		||||
    [:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
 | 
			
		||||
    [:favourite, :reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def report_type?
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,14 @@ class REST::ReactionSerializer < ActiveModel::Serializer
 | 
			
		|||
    object.custom_emoji.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def name
 | 
			
		||||
    if extern?
 | 
			
		||||
      [object.name, '@', object.custom_emoji.domain].join
 | 
			
		||||
    else
 | 
			
		||||
      object.name
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def url
 | 
			
		||||
    full_asset_url(object.custom_emoji.image.url)
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -28,4 +36,10 @@ class REST::ReactionSerializer < ActiveModel::Serializer
 | 
			
		|||
  def static_url
 | 
			
		||||
    full_asset_url(object.custom_emoji.image.url(:static))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def extern?
 | 
			
		||||
    custom_emoji? && object.custom_emoji.domain.present?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
 | 
			
		|||
  has_many :ordered_mentions, key: :mentions
 | 
			
		||||
  has_many :tags
 | 
			
		||||
  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
			
		||||
  has_many :reactions, serializer: REST::ReactionSerializer
 | 
			
		||||
 | 
			
		||||
  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
 | 
			
		||||
  has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
 | 
			
		||||
| 
						 | 
				
			
			@ -146,6 +147,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
 | 
			
		|||
    object.active_mentions.to_a.sort_by(&:id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reactions
 | 
			
		||||
    object.reactions(current_user&.account)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  class ApplicationSerializer < ActiveModel::Serializer
 | 
			
		||||
    attributes :name, :website
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -96,6 +96,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
 | 
			
		|||
        min_expiration: PollValidator::MIN_EXPIRATION,
 | 
			
		||||
        max_expiration: PollValidator::MAX_EXPIRATION,
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      reactions: {
 | 
			
		||||
        max_reactions: StatusReactionValidator::LIMIT,
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										27
									
								
								app/services/react_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/services/react_service.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class ReactService < BaseService
 | 
			
		||||
  include Authorization
 | 
			
		||||
  include Payloadable
 | 
			
		||||
 | 
			
		||||
  def call(account, status, emoji)
 | 
			
		||||
    name, domain = emoji.split('@')
 | 
			
		||||
    custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain)
 | 
			
		||||
    reaction = StatusReaction.find_by(account: account, status: status, name: name, custom_emoji: custom_emoji)
 | 
			
		||||
    return reaction unless reaction.nil?
 | 
			
		||||
 | 
			
		||||
    reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji)
 | 
			
		||||
 | 
			
		||||
    json = Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer))
 | 
			
		||||
    if status.account.local?
 | 
			
		||||
      NotifyService.new.call(status.account, :reaction, reaction)
 | 
			
		||||
      ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
 | 
			
		||||
    else
 | 
			
		||||
      ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ActivityTracker.increment('activity:interactions')
 | 
			
		||||
 | 
			
		||||
    reaction
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										21
									
								
								app/services/unreact_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/services/unreact_service.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class UnreactService < BaseService
 | 
			
		||||
  include Payloadable
 | 
			
		||||
 | 
			
		||||
  def call(account, status, name)
 | 
			
		||||
    reaction = StatusReaction.find_by(account: account, status: status, name: name)
 | 
			
		||||
    return if reaction.nil?
 | 
			
		||||
 | 
			
		||||
    reaction.destroy!
 | 
			
		||||
 | 
			
		||||
    json = Oj.dump(serialize_payload(reaction, ActivityPub::UndoEmojiReactionSerializer))
 | 
			
		||||
    if status.account.local?
 | 
			
		||||
      ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
 | 
			
		||||
    else
 | 
			
		||||
      ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    reaction
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										24
									
								
								app/validators/status_reaction_validator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/validators/status_reaction_validator.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class StatusReactionValidator < ActiveModel::Validator
 | 
			
		||||
  SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze
 | 
			
		||||
 | 
			
		||||
  LIMIT = [1, (ENV['MAX_REACTIONS'] || 1).to_i].max
 | 
			
		||||
 | 
			
		||||
  def validate(reaction)
 | 
			
		||||
    return if reaction.name.blank?
 | 
			
		||||
 | 
			
		||||
    reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name)
 | 
			
		||||
    reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if reaction.account.local? && limit_reached?(reaction)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def unicode_emoji?(name)
 | 
			
		||||
    SUPPORTED_EMOJIS.include?(name)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def limit_reached?(reaction)
 | 
			
		||||
    reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +38,9 @@
 | 
			
		|||
  .fields-group
 | 
			
		||||
    = f.input :setting_crop_images, as: :boolean, wrapper: :with_label
 | 
			
		||||
 | 
			
		||||
  .fields-group.fields-row__column.fields-row__column-6
 | 
			
		||||
    = f.input :setting_visible_reactions, wrapper: :with_label, input_html: { type: 'number', min: '0', data: { default: '6' } }, hint: false
 | 
			
		||||
 | 
			
		||||
  %h4= t 'appearance.discovery'
 | 
			
		||||
 | 
			
		||||
  .fields-group
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,5 +38,10 @@ de:
 | 
			
		|||
      title: Benutzer-Verifizierung
 | 
			
		||||
  generic:
 | 
			
		||||
    use_this: Benutze das
 | 
			
		||||
  notification_mailer:
 | 
			
		||||
    reaction:
 | 
			
		||||
      body: "%{name} hat auf deinen Beitrag reagiert:"
 | 
			
		||||
      subject: "%{name} hat auf deinen Beitrag reagiert"
 | 
			
		||||
      title: Neue Reaktion
 | 
			
		||||
  settings:
 | 
			
		||||
    flavours: Varianten
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,5 +38,10 @@ en:
 | 
			
		|||
      title: User verification
 | 
			
		||||
  generic:
 | 
			
		||||
    use_this: Use this
 | 
			
		||||
  notification_mailer:
 | 
			
		||||
    reaction:
 | 
			
		||||
      body: "%{name} reacted to your post:"
 | 
			
		||||
      subject: "%{name} reacted to your post"
 | 
			
		||||
      title: New reaction
 | 
			
		||||
  settings:
 | 
			
		||||
    flavours: Flavours
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,5 +38,10 @@ fr:
 | 
			
		|||
      title: Vérification de l'utilisateur
 | 
			
		||||
  generic:
 | 
			
		||||
    use_this: Utiliser ceci
 | 
			
		||||
  notification_mailer:
 | 
			
		||||
    reaction:
 | 
			
		||||
      body: "%{name} a réagi·e à votre message:"
 | 
			
		||||
      subject: "%{name} a réagi·e à votre message"
 | 
			
		||||
      title: Nouvelle réaction
 | 
			
		||||
  settings:
 | 
			
		||||
    flavours: Thèmes
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ de:
 | 
			
		|||
        setting_hide_followers_count: Anzahl der Follower verbergen
 | 
			
		||||
        setting_skin: Skin
 | 
			
		||||
        setting_system_emoji_font: Systemschriftart für Emojis verwenden (nur für Glitch-Variante)
 | 
			
		||||
        setting_visible_reactions: Anzahl der sichtbaren Emoji-Reaktionen
 | 
			
		||||
      notification_emails:
 | 
			
		||||
        trending_link: Neuer angesagter Link muss überprüft werden
 | 
			
		||||
        trending_status: Neuer angesagter Post muss überprüft werden
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ en:
 | 
			
		|||
        setting_hide_followers_count: Hide your followers count
 | 
			
		||||
        setting_skin: Skin
 | 
			
		||||
        setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
 | 
			
		||||
        setting_visible_reactions: Number of visible emoji reactions
 | 
			
		||||
      notification_emails:
 | 
			
		||||
        trending_link: New trending link requires review
 | 
			
		||||
        trending_status: New trending post requires review
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ fr:
 | 
			
		|||
        setting_hide_followers_count: Cacher votre nombre d'abonné·e·s
 | 
			
		||||
        setting_skin: Thème
 | 
			
		||||
        setting_system_emoji_font: Utiliser la police par défaut du système pour les émojis (s'applique uniquement au mode Glitch)
 | 
			
		||||
        setting_visible_reactions: Nombre de réactions emoji visibles
 | 
			
		||||
      notification_emails:
 | 
			
		||||
        trending_link: Un nouveau lien en tendances nécessite un examen
 | 
			
		||||
        trending_status: Un nouveau post en tendances nécessite un examen
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -721,6 +721,10 @@ en_GB:
 | 
			
		|||
      body: 'You were mentioned by %{name} in:'
 | 
			
		||||
      subject: You were mentioned by %{name}
 | 
			
		||||
      title: New mention
 | 
			
		||||
    reaction:
 | 
			
		||||
      body: "%{name} reacted on your post with %{reaction}:"
 | 
			
		||||
      subject: "%{name} reacted on your post"
 | 
			
		||||
      title: New reaction
 | 
			
		||||
    reblog:
 | 
			
		||||
      body: 'Your status was boosted by %{name}:'
 | 
			
		||||
      subject: "%{name} boosted your status"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -442,6 +442,11 @@ Rails.application.routes.draw do
 | 
			
		|||
          resource :favourite, only: :create
 | 
			
		||||
          post :unfavourite, to: 'favourites#destroy'
 | 
			
		||||
 | 
			
		||||
          # foreign custom emojis are encoded as shortcode@domain.tld
 | 
			
		||||
          # the constraint prevents rails from interpreting the ".tld" as a filename extension
 | 
			
		||||
          post '/react/:id', to: 'reactions#create', constraints: { id: /[^\/]+/ }
 | 
			
		||||
          post '/unreact/:id', to: 'reactions#destroy', constraints: { id: /[^\/]+/ }
 | 
			
		||||
 | 
			
		||||
          resource :bookmark, only: :create
 | 
			
		||||
          post :unbookmark, to: 'bookmarks#destroy'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,6 +42,7 @@ defaults: &defaults
 | 
			
		|||
  trendable_by_default: false
 | 
			
		||||
  trending_status_cw: true
 | 
			
		||||
  crop_images: true
 | 
			
		||||
  visible_reactions: 6
 | 
			
		||||
  notification_emails:
 | 
			
		||||
    follow: true
 | 
			
		||||
    reblog: false
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								db/migrate/20221124114030_create_status_reactions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								db/migrate/20221124114030_create_status_reactions.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
class CreateStatusReactions < ActiveRecord::Migration[6.1]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :status_reactions do |t|
 | 
			
		||||
      t.references :account, null: false, foreign_key: { on_delete: :cascade }
 | 
			
		||||
      t.references :status, null: false, foreign_key: { on_delete: :cascade }
 | 
			
		||||
      t.string :name, null: false, default: ''
 | 
			
		||||
      t.references :custom_emoji, null: true, foreign_key: { on_delete: :cascade }
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    add_index :status_reactions, [:account_id, :status_id, :name], unique: true, name: :index_status_reactions_on_account_id_and_status_id
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										16
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								db/schema.rb
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -897,6 +897,19 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) do
 | 
			
		|||
    t.index ["status_id"], name: "index_status_pins_on_status_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "status_reactions", force: :cascade do |t|
 | 
			
		||||
    t.bigint "account_id", null: false
 | 
			
		||||
    t.bigint "status_id", null: false
 | 
			
		||||
    t.string "name", default: "", null: false
 | 
			
		||||
    t.bigint "custom_emoji_id"
 | 
			
		||||
    t.datetime "created_at", precision: 6, null: false
 | 
			
		||||
    t.datetime "updated_at", precision: 6, null: false
 | 
			
		||||
    t.index ["account_id", "status_id", "name"], name: "index_status_reactions_on_account_id_and_status_id", unique: true
 | 
			
		||||
    t.index ["account_id"], name: "index_status_reactions_on_account_id"
 | 
			
		||||
    t.index ["custom_emoji_id"], name: "index_status_reactions_on_custom_emoji_id"
 | 
			
		||||
    t.index ["status_id"], name: "index_status_reactions_on_status_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "status_stats", force: :cascade do |t|
 | 
			
		||||
    t.bigint "status_id", null: false
 | 
			
		||||
    t.bigint "replies_count", default: 0, null: false
 | 
			
		||||
| 
						 | 
				
			
			@ -1213,6 +1226,9 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) do
 | 
			
		|||
  add_foreign_key "status_edits", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_pins", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_reactions", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_reactions", "custom_emojis", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_reactions", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_stats", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_trends", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "status_trends", "statuses", on_delete: :cascade
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								spec/fabricators/status_reaction_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								spec/fabricators/status_reaction_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
Fabricator(:status_reaction) do
 | 
			
		||||
  account      nil
 | 
			
		||||
  status       nil
 | 
			
		||||
  name         "MyString"
 | 
			
		||||
  custom_emoji nil
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										5
									
								
								spec/models/status_reaction_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/models/status_reaction_spec.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe StatusReaction, type: :model do
 | 
			
		||||
  pending "add some examples to (or delete) #{__FILE__}"
 | 
			
		||||
end
 | 
			
		||||
		Loading…
	
		Reference in a new issue