Skip to content

Commit

Permalink
Add support for emoji reactions
Browse files Browse the repository at this point in the history
Squashed, modified, and rebased from glitch-soc#2221.

Co-authored-by: fef <owo@fef.moe>
Co-authored-by: Jeremy Kescher <jeremy@kescher.at>
Co-authored-by: neatchee <neatchee@gmail.com>
Co-authored-by: Ivan Rodriguez <104603218+IRod22@users.noreply.github.com>
Co-authored-by: Plastikmensch <plastikmensch@users.noreply.github.com>
  • Loading branch information
6 people committed Dec 2, 2024
1 parent 5550f53 commit 21c303a
Show file tree
Hide file tree
Showing 55 changed files with 1,020 additions and 11 deletions.
3 changes: 3 additions & 0 deletions .env.production.sample
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,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
Expand Down
31 changes: 31 additions & 0 deletions app/controllers/api/v1/statuses/reactions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 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 json: @status, serializer: REST::StatusSerializer
end

def destroy
UnreactWorker.perform_async(current_account.id, @status.id, params[:id])

render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reactions_map: { @status.id => false })
rescue Mastodon::NotPermittedError
not_found
end

private

def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end
82 changes: 82 additions & 0 deletions app/javascript/flavours/glitch/actions/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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 * from "./interactions_typed";

export function favourite(status) {
Expand Down Expand Up @@ -494,3 +504,75 @@ export function toggleFavourite(statusId, skipModal = false) {
}
};
}

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,
});
1 change: 1 addition & 0 deletions app/javascript/flavours/glitch/actions/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',
Expand Down
15 changes: 14 additions & 1 deletion app/javascript/flavours/glitch/components/status.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Card from '../features/status/components/card';
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia } from '../initial_state';
import { displayMedia, visibleReactions } from '../initial_state';

import AttachmentList from './attachment_list';
import { CollapseButton } from './collapse_button';
Expand All @@ -31,6 +31,7 @@ import StatusContent from './status_content';
import StatusHeader from './status_header';
import StatusIcons from './status_icons';
import StatusPrepend from './status_prepend';
import StatusReactions from './status_reactions';

const domParser = new DOMParser();

Expand Down Expand Up @@ -91,6 +92,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,
Expand Down Expand Up @@ -759,6 +762,7 @@ class Status extends ImmutablePureComponent {
if (this.props.prepend && account) {
const notifKind = {
favourite: 'favourited',
reaction: 'reacted',
reblog: 'boosted',
reblogged_by: 'boosted',
status: 'posted',
Expand Down Expand Up @@ -844,6 +848,15 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>

<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}
Expand Down
31 changes: 30 additions & 1 deletion app/javascript/flavours/glitch/components/status_action_bar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';

import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
Expand All @@ -27,7 +28,8 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';

import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { me } from '../initial_state';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { me, maxReactions } from '../initial_state';

import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
Expand All @@ -49,6 +51,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: 'Favorite' },
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}' },
Expand All @@ -73,6 +76,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReactionAdd: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
Expand Down Expand Up @@ -130,6 +134,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.props.identity;

Expand Down Expand Up @@ -205,6 +213,8 @@ 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 { permissions, signedIn } = this.props.identity;
Expand Down Expand Up @@ -320,6 +330,18 @@ class StatusActionBar extends ImmutablePureComponent {
</div>
);

const canReact = permissions && 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='add_reaction'
iconComponent={AddReactionIcon}
/>
);

return (
<div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
Expand All @@ -339,6 +361,13 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
</div>
<div className='status__action-bar__button-wrapper'>
{
permissions
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
Expand Down
13 changes: 13 additions & 0 deletions app/javascript/flavours/glitch/components/status_prepend.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl';

import ImmutablePropTypes from 'react-immutable-proptypes';

import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
Expand Down Expand Up @@ -70,6 +71,14 @@ export default class StatusPrepend extends PureComponent {
values={{ name : link }}
/>
);
case 'reaction':
return (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your status'
values={{ name: link }}
/>
);
case 'reblog':
return (
<FormattedMessage
Expand Down Expand Up @@ -125,6 +134,10 @@ export default class StatusPrepend extends PureComponent {
iconId = 'star';
iconComponent = StarIcon;
break;
case 'reaction':
iconId = 'add_reaction';
iconComponent = AddReactionIcon;
break;
case 'featured':
iconId = 'thumb-tack';
iconComponent = PushPinIcon;
Expand Down
Loading

0 comments on commit 21c303a

Please sign in to comment.