diff --git a/.env.production.sample b/.env.production.sample index d14f0214907d71..a12cdffbcffc0c 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -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 diff --git a/app/controllers/api/v1/statuses/reactions_controller.rb b/app/controllers/api/v1/statuses/reactions_controller.rb new file mode 100644 index 00000000000000..c4b0fa307f419a --- /dev/null +++ b/app/controllers/api/v1/statuses/reactions_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:favourites' } + before_action :require_user! + + 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 +end diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 92142d782c1c90..725e29985d577b 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -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) { @@ -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: + // + 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, +}); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7b80663f3ddb52..27a6d701915749 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts index d173083dbd2143..1f3bc4d1ebcd28 100644 --- a/app/javascript/flavours/glitch/api_types/notifications.ts +++ b/app/javascript/flavours/glitch/api_types/notifications.ts @@ -11,6 +11,7 @@ export const allNotificationTypes = [ 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', @@ -25,6 +26,7 @@ export const allNotificationTypes = [ export type NotificationWithStatusType = | 'favourite' + | 'reaction' | 'reblog' | 'status' | 'mention' diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 30ce6c49177efe..8f7f278891e855 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -12,6 +12,7 @@ import { HotKeys } from 'react-hotkeys'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import PollContainer from 'flavours/glitch/containers/poll_container'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; +import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router'; @@ -21,7 +22,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'; @@ -31,6 +32,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(); @@ -76,6 +78,7 @@ class Status extends ImmutablePureComponent { static contextType = SensitiveMediaContext; static propTypes = { + identity: identityContextPropShape, containerId: PropTypes.string, id: PropTypes.string, status: ImmutablePropTypes.map, @@ -91,6 +94,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, @@ -541,6 +546,7 @@ class Status extends ImmutablePureComponent { onOpenMedia, notification, history, + identity, ...other } = this.props; const { isCollapsed } = this.state; @@ -759,6 +765,7 @@ class Status extends ImmutablePureComponent { if (this.props.prepend && account) { const notifKind = { favourite: 'favourited', + reaction: 'reacted', reblog: 'boosted', reblogged_by: 'boosted', status: 'posted', @@ -844,6 +851,15 @@ class Status extends ImmutablePureComponent { {...statusContentProps} /> + + {(!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar']))) && ( { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + }; + handleReblogClick = e => { const { signedIn } = this.props.identity; @@ -320,6 +328,8 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + return (
@@ -339,6 +349,9 @@ class StatusActionBar extends ImmutablePureComponent {
+
+ +
diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index b83767a9901eb3..fe880551d16405 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -9,6 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; 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'; +import MoodIcon from '@/material-icons/400-24px/mood.svg?react'; import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; @@ -70,6 +71,14 @@ export default class StatusPrepend extends PureComponent { values={{ name : link }} /> ); + case 'reaction': + return ( + + ); case 'reblog': return ( 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 ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func, + removeReaction: PropTypes.func, + canReact: PropTypes.bool.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction, canReact } = this.props; + if (!canReact) return; + + if (reaction.get('me') && removeReaction) { + removeReaction(statusId, reaction.get('name')); + } else if (addReaction) { + addReaction(statusId, reaction.get('name')); + } + }; + + handleMouseEnter = () => this.setState({ hovered: true }); + + handleMouseLeave = () => this.setState({ hovered: false }); + + render() { + const { reaction } = this.props; + + return ( + + ); + } + +} + +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 ( + {emoji} + ); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 493a01da2390a0..011444c390028c 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -16,6 +16,8 @@ import { unbookmark, pin, unpin, + addReaction, + removeReaction, } from 'flavours/glitch/actions/interactions'; import { openModal } from 'flavours/glitch/actions/modal'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; @@ -106,6 +108,14 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ } }, + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal({ modalType: 'EMBED', diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index c556f1536689f3..ac835d51ef82c1 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -327,6 +327,9 @@ class EmojiPickerDropdown extends PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + title: PropTypes.string, + icon: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -361,7 +364,7 @@ class EmojiPickerDropdown extends 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 { @@ -389,19 +392,18 @@ class EmojiPickerDropdown extends PureComponent { }; render () { - const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; - const title = intl.formatMessage(messages.emoji); + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, title, icon, disabled } = this.props; const { active, loading, placement } = this.state; return (
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx index 81a9d9e1d1f4ed..0377def8219ea4 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx @@ -71,7 +71,7 @@ class ColumnSettings extends PureComponent {

- +
@@ -125,6 +125,17 @@ class ColumnSettings extends PureComponent {
+
+

+ +
+ + {showPushSettings && } + + +
+
+

diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx index cba2e87d2f5e32..f2f6ae5fa13510 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx @@ -207,6 +207,31 @@ class Notification extends ImmutablePureComponent { ); } + renderReaction (notification) { + return ( +