diff --git a/app/javascript/flavours/glitch/components/status_reactions.jsx b/app/javascript/flavours/glitch/components/status_reactions.jsx new file mode 100644 index 0000000000..1ee8a0a028 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_reactions.jsx @@ -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 ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +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 ( + + ); + } + +} + +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/locales/defaultMessages.json b/app/javascript/flavours/glitch/locales/defaultMessages.json index bf21fdef2d..8feefa4a49 100644 --- a/app/javascript/flavours/glitch/locales/defaultMessages.json +++ b/app/javascript/flavours/glitch/locales/defaultMessages.json @@ -78,6 +78,15 @@ ], "path": "app/javascript/flavours/glitch/components/notification_purge_buttons.json" }, + { + "descriptors": [ + { + "defaultMessage": "React", + "id": "status.react" + } + ], + "path": "app/javascript/flavours/glitch/components/status_action_bar.json" + }, { "descriptors": [ { @@ -119,6 +128,15 @@ ], "path": "app/javascript/flavours/glitch/components/status_icons.json" }, + { + "descriptors": [ + { + "defaultMessage": "{name} reacted to your status", + "id": "notification.reaction" + } + ], + "path": "app/javascript/flavours/glitch/components/status_prepend.json" + }, { "descriptors": [ { @@ -903,6 +921,24 @@ ], "path": "app/javascript/flavours/glitch/features/local_settings/page/index.json" }, + { + "descriptors": [ + { + "defaultMessage": "Reactions:", + "id": "notifications.column_settings.reaction" + } + ], + "path": "app/javascript/flavours/glitch/features/notifications/components/column_settings.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Reactions", + "id": "notifications.filter.reactions" + } + ], + "path": "app/javascript/flavours/glitch/features/notifications/components/filter_bar.json" + }, { "descriptors": [ { @@ -956,6 +992,15 @@ ], "path": "app/javascript/flavours/glitch/features/reblogs/index.json" }, + { + "descriptors": [ + { + "defaultMessage": "React", + "id": "status.react" + } + ], + "path": "app/javascript/flavours/glitch/features/status/components/action_bar.json" + }, { "descriptors": [ { @@ -1110,5 +1155,50 @@ } ], "path": "app/javascript/flavours/glitch/features/ui/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "React", + "id": "status.react" + } + ], + "path": "app/javascript/mastodon/components/status_action_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Reactions:", + "id": "notifications.column_settings.reaction" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Reactions", + "id": "notifications.filter.reactions" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/filter_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "{name} reacted to your status", + "id": "notification.reaction" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/notification.json" + }, + { + "descriptors": [ + { + "defaultMessage": "React", + "id": "status.react" + } + ], + "path": "app/javascript/mastodon/features/status/components/action_bar.json" } ] \ No newline at end of file diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 6d16f23efe..dd4e5c0fb0 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -76,12 +76,13 @@ "navigation_bar.misc": "Misc", "notification.markForDeletion": "Mark for deletion", "notification.reaction": "{name} reacted to your post", - "notifications.column_settings.reaction": "Reactions:", "notification_purge.btn_all": "Select\nall", "notification_purge.btn_apply": "Clear\nselected", "notification_purge.btn_invert": "Invert\nselection", "notification_purge.btn_none": "Select\nnone", "notification_purge.start": "Enter notification cleaning mode", + "notifications.column_settings.reaction": "Reactions:", + "notifications.filter.reactions": "Reactions", "notifications.marked_clear": "Clear selected notifications", "notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?", "onboarding.done": "Done", @@ -201,6 +202,7 @@ "status.in_reply_to": "This toot is a reply", "status.is_poll": "This toot is a poll", "status.local_only": "Only visible from your instance", + "status.react": "React", "status.sensitive_toggle": "Click to view", "status.uncollapse": "Uncollapse", "web_app_crash.change_your_settings": "Change your {settings}", diff --git a/app/javascript/mastodon/components/status_reactions.jsx b/app/javascript/mastodon/components/status_reactions.jsx new file mode 100644 index 0000000000..1ee8a0a028 --- /dev/null +++ b/app/javascript/mastodon/components/status_reactions.jsx @@ -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 ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +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 ( + + ); + } + +} + +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/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 00d724a282..572ad907e2 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -179,23 +179,20 @@ class ActivityPub::Activity nil end - # Ensure all emojis declared in the activity's tags are + # Ensure emoji 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_emoji_tags(name, tags) + tag = as_array(tags).find { |item| item['type'] == 'Emoji' } + return if tag.nil? - 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? + return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? || !name.eql?(custom_emoji_parser.shortcode) 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) + return emoji 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, @@ -205,6 +202,8 @@ class ActivityPub::Activity emoji.save rescue Seahorse::Client::NetworkingError => e Rails.logger.warn "Error fetching emoji: #{e}" + return end + emoji end end diff --git a/app/lib/activitypub/activity/emoji_react.rb b/app/lib/activitypub/activity/emoji_react.rb index 526f22803d..c9d88bc51c 100644 --- a/app/lib/activitypub/activity/emoji_react.rb +++ b/app/lib/activitypub/activity/emoji_react.rb @@ -6,20 +6,21 @@ class ActivityPub::Activity::EmojiReact < ActivityPub::Activity name = @json['content'] return if original_status.nil? || !original_status.account.local? || - delete_arrived_first?(@json['id']) || - @account.reacted?(original_status, name) + delete_arrived_first?(@json['id']) - custom_emoji = nil if /^:.*:$/.match?(name) - process_emoji_tags(@json['tag']) - name.delete! ':' - custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain) + custom_emoji = process_emoji_tags(name, @json['tag']) + return if custom_emoji.nil? end + return if @account.reacted?(original_status, name, custom_emoji) + 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') + rescue ActiveRecord::RecordInvalid + nil end end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index 8acc99b9db..86d70e0d70 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -5,7 +5,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity original_status = status_from_uri(object_uri) return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) - return if maybe_process_misskey_reaction(original_status) + return if maybe_process_misskey_reaction return if @account.favourited?(original_status) @@ -17,22 +17,24 @@ class ActivityPub::Activity::Like < ActivityPub::Activity # 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) + def maybe_process_misskey_reaction + original_status = status_from_uri(object_uri) name = @json['_misskey_reaction'] return false if name.nil? - custom_emoji = nil if /^:.*:$/.match?(name) - process_emoji_tags(@json['tag']) - name.delete! ':' - custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain) + custom_emoji = process_emoji_tags(name, @json['tag']) + return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like end - return true if @account.reacted?(original_status, name) + return true if @account.reacted?(original_status, name, custom_emoji) 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 + # account tried to react with disabled custom emoji. Returning true to discard activity. + rescue ActiveRecord::RecordInvalid + true end end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index 54cda1947f..b453b4ddc8 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -110,6 +110,31 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity if @account.favourited?(status) favourite = status.favourites.where(account: @account).first favourite&.destroy + elsif @object['_misskey_reaction'].present? + undo_emoji_react + else + delete_later!(object_uri) + end + end + + def undo_emoji_react + name = @object['content'] || @object['_misskey_reaction'] + return if name.nil? + + status = status_from_uri(target_uri) + + return if status.nil? || !status.account.local? + + if /^:.*:$/.match?(name) + name.delete! ':' + custom_emoji = process_emoji_tags(name, @object['tag']) + + return if custom_emoji.nil? + end + + if @account.reacted?(status, name, custom_emoji) + reaction = status.status_reactions.where(account: @account, name: name).first + reaction&.destroy else delete_later!(object_uri) end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 89edd50ed2..e3d2449733 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -243,8 +243,8 @@ module AccountInteractions status.proper.favourites.where(account: self).exists? end - def reacted?(status, name) - status.proper.status_reactions.where(account: self, name: name).exists? + def reacted?(status, name, custom_emoji = nil) + status.proper.status_reactions.where(account: self, name: name, custom_emoji: custom_emoji).exists? end def bookmarked?(status) diff --git a/app/models/status.rb b/app/models/status.rb index a8d600ef1a..8a70fa7322 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -300,7 +300,7 @@ class Status < ApplicationRecord 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") + 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 and (r.custom_emoji_id = status_reactions.custom_emoji_id or r.custom_emoji_id is null and status_reactions.custom_emoji_id is null)) as me") end end diff --git a/app/models/status_reaction.rb b/app/models/status_reaction.rb index a54d03a309..bc6eda2a1c 100644 --- a/app/models/status_reaction.rb +++ b/app/models/status_reaction.rb @@ -26,7 +26,8 @@ class StatusReaction < ApplicationRecord private + # Sets custom_emoji to nil when disabled def set_custom_emoji - self.custom_emoji = CustomEmoji.find_by(shortcode: name, domain: account.domain) if name.blank? + self.custom_emoji = CustomEmoji.find_by(disabled: false, shortcode: name, domain: custom_emoji.domain) if name.present? && custom_emoji.present? end end diff --git a/app/services/react_service.rb b/app/services/react_service.rb index 79d1eaaf30..de2fd1de9c 100644 --- a/app/services/react_service.rb +++ b/app/services/react_service.rb @@ -8,6 +8,8 @@ class ReactService < BaseService authorize_with account, status, :react? name, domain = emoji.split('@') + return unless domain.nil? || status.local? + 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? diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb index 0338bf531a..046476de9d 100644 --- a/app/validators/status_reaction_validator.rb +++ b/app/validators/status_reaction_validator.rb @@ -19,7 +19,7 @@ class StatusReactionValidator < ActiveModel::Validator end def new_reaction?(reaction) - !reaction.status.status_reactions.exists?(status: reaction.status, account: reaction.account, name: reaction.name) + !reaction.status.status_reactions.exists?(status: reaction.status, account: reaction.account, name: reaction.name, custom_emoji: reaction.custom_emoji) end def limit_reached?(reaction) diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml index e88e98191e..fecaa8beae 100644 --- a/config/locales-glitch/en.yml +++ b/config/locales-glitch/en.yml @@ -38,6 +38,11 @@ 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 notification_mailer: diff --git a/spec/models/status_reaction_spec.rb b/spec/models/status_reaction_spec.rb index f0467ce7b2..ccfa9ee8d8 100644 --- a/spec/models/status_reaction_spec.rb +++ b/spec/models/status_reaction_spec.rb @@ -1,7 +1,3 @@ # frozen_string_literal: true require 'rails_helper' - -RSpec.describe StatusReaction do - pending "add some examples to (or delete) #{__FILE__}" -end