From 5f7da7bff1a39ef55c1920d719010a53dc070d6e Mon Sep 17 00:00:00 2001 From: KMY Date: Fri, 24 Feb 2023 20:57:01 +0900 Subject: [PATCH] Add api for set emoji reactions to toot --- .../v1/statuses/emoji_reactions_controller.rb | 45 +++++++++ .../mastodon/actions/interactions.js | 91 +++++++++++++++++++ app/javascript/mastodon/components/status.jsx | 2 + .../mastodon/components/status_action_bar.jsx | 21 +++-- .../components/status_emoji_reactions_bar.jsx | 10 +- .../mastodon/containers/status_container.jsx | 10 ++ .../containers/notification_container.js | 4 + .../containers/detailed_status_container.js | 4 + app/lib/potential_friendship_tracker.rb | 1 + app/models/concerns/account_associations.rb | 1 + app/models/custom_emoji.rb | 1 + app/models/emoji_reaction.rb | 2 +- app/models/notification.rb | 7 +- app/policies/status_policy.rb | 4 + ...ji_reactions_grouped_by_name_serializer.rb | 16 +++- app/services/emoji_react_service.rb | 72 +++++++++++++++ config/routes.rb | 3 + 17 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 app/controllers/api/v1/statuses/emoji_reactions_controller.rb create mode 100644 app/services/emoji_react_service.rb diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb new file mode 100644 index 0000000000..efeb8e9929 --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::EmojiReactionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:emoji_reactions' } + before_action :require_user! + before_action :set_status, only: [:update] + + # For compatible with Fedibird API + def update + create_private + end + + # TODO: destroy emoji reaction api + def destroy + # fav = current_account.favourites.find_by(status_id: params[:status_id]) + + # if fav + # @status = fav.status + # UnfavouriteWorker.perform_async(current_account.id, @status.id) + # else + # @status = Status.find(params[:status_id]) + # authorize @status, :show? + # end + + # render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) + # rescue Mastodon::NotPermittedError + not_found + end + + private + + def create_private + EmojiReactService.new.call(current_account, @status, params[:id]) + render json: @status, serializer: REST::StatusSerializer + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index bc35736ffa..15b466141c 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -9,6 +9,10 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; +export const EMOJIREACT_REQUEST = 'EMOJIREACT_REQUEST'; +export const EMOJIREACT_SUCCESS = 'EMOJIREACT_SUCCESS'; +export const EMOJIREACT_FAIL = 'EMOJIREACT_FAIL'; + export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; @@ -17,6 +21,10 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; +export const UNEMOJIREACT_REQUEST = 'UNEMOJIREACT_REQUEST'; +export const UNEMOJIREACT_SUCCESS = 'UNEMOJIREACT_SUCCESS'; +export const UNEMOJIREACT_FAIL = 'UNEMOJIREACT_FAIL'; + export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; @@ -195,6 +203,89 @@ export function unfavouriteFail(status, error) { }; } +export function emojiReact(status, emoji) { + return function (dispatch, getState) { + dispatch(emojiReactRequest(status, emoji)); + console.dir(emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native); + + api(getState).put(`/api/v1/statuses/${status.get('id')}/emoji_reactions/${emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native}`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(emojiReactSuccess(status, emoji)); + }).catch(function (error) { + dispatch(emojiReactFail(status, emoji, error)); + }); + }; +} + +export function unEmojiReact(status, emoji) { + return (dispatch, getState) => { + dispatch(unEmojiReactRequest(status, emoji)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreactions/${emoji.native}`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unEmojiReactSuccess(status, emoji)); + }).catch(error => { + dispatch(unEmojiReactFail(status, emoji, error)); + }); + }; +} + +export function emojiReactRequest(status, emoji) { + return { + type: EMOJIREACT_REQUEST, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function emojiReactSuccess(status, emoji) { + return { + type: EMOJIREACT_SUCCESS, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function emojiReactFail(status, emoji, error) { + return { + type: EMOJIREACT_FAIL, + status: status, + emoji: emoji, + error: error, + skipLoading: true, + }; +} + +export function unEmojiReactRequest(status, emoji) { + return { + type: UNEMOJIREACT_REQUEST, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function unEmojiReactSuccess(status, emoji) { + return { + type: UNEMOJIREACT_SUCCESS, + status: status, + emoji: emoji, + skipLoading: true, + }; +} + +export function unEmojiReactFail(status, emoji, error) { + return { + type: UNEMOJIREACT_FAIL, + status: status, + emoji: emoji, + error: error, + skipLoading: true, + }; +} + export function bookmark(status) { return function (dispatch, getState) { dispatch(bookmarkRequest(status)); diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 28b1cf321d..ceda971feb 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -73,6 +73,8 @@ class Status extends ImmutablePureComponent { onClick: PropTypes.func, onReply: PropTypes.func, onFavourite: PropTypes.func, + onEmojiReact: PropTypes.func, + onUnEmojiReact: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index f6967219dc..282892809c 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -68,6 +68,7 @@ class StatusActionBar extends ImmutablePureComponent { relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, + onEmojiReact: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, @@ -129,6 +130,16 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleEmojiPick = (data) => { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onEmojiReact(this.props.status, data); + } else { + this.props.onInteractionModal('favourite', this.props.status); + } + }; + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -232,16 +243,6 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); }; - handleEmojiPick = (data) => { - /* - const { text } = this.props; - const position = this.autosuggestTextarea.textarea.selectionStart; - const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); - - this.props.onPickEmoji(position, data, needsSpace); - */ - }; - render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.context.identity; diff --git a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx index 9761fcca39..d91525430d 100644 --- a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx +++ b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx @@ -8,7 +8,7 @@ import classNames from 'classnames'; class EmojiReactionButton extends React.PureComponent { static propTypes = { - name: ImmutablePropTypes.map, + name: PropTypes.string, url: PropTypes.string, staticUrl: PropTypes.string, count: PropTypes.number.isRequired, @@ -22,7 +22,7 @@ class EmojiReactionButton extends React.PureComponent { let emojiHtml = null; if (url) { let customEmojis = {}; - customEmojis[name] = { url, static_url: staticUrl }; + customEmojis[`:${name}:`] = { url, static_url: staticUrl }; emojiHtml = emojify(`:${name}:`, customEmojis); } else { emojiHtml = emojify(name); @@ -47,16 +47,16 @@ export default @injectIntl class StatusEmojiReactionsBar extends React.PureComponent { static propTypes = { - emojiReactions: ImmutablePropTypes.map.isRequired, + emojiReactions: ImmutablePropTypes.list.isRequired, statusId: PropTypes.string, }; render () { const { emojiReactions, statusId } = this.props; - const emojiButtons = React.Children.map(emojiReactions, (emoji) => ( + const emojiButtons = Array.from(emojiReactions).map((emoji, index) => ( ({ } }, + onEmojiReact (status, emoji) { + dispatch(emojiReact(status, emoji)); + }, + + onUnEmojiReact (status, emoji) { + dispatch(unEmojiReact(status, emoji)); + }, + onBookmark (status) { if (status.get('bookmarked')) { dispatch(unbookmark(status)); diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 8c5688acba..9220d0ad95 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -61,6 +61,10 @@ const mapDispatchToProps = dispatch => ({ } }, + onEmojiReact (status, emoji) { + + }, + onToggleHidden (status) { if (status.get('hidden')) { dispatch(revealStatus(status.get('id'))); diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index bfed166200..546f60db80 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -93,6 +93,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onEmojiReact (status, emoji) { + + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb index f5bc203465..4fbdec5bbf 100644 --- a/app/lib/potential_friendship_tracker.rb +++ b/app/lib/potential_friendship_tracker.rb @@ -6,6 +6,7 @@ class PotentialFriendshipTracker WEIGHTS = { reply: 1, + emoji_reaction: 5, favourite: 10, reblog: 20, }.freeze diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index bbe269e8f0..3435f7a9e5 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -13,6 +13,7 @@ module AccountAssociations # Timelines has_many :statuses, inverse_of: :account, dependent: :destroy has_many :favourites, inverse_of: :account, dependent: :destroy + has_many :emoji_reactions, inverse_of: :account, dependent: :destroy has_many :bookmarks, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 3d7900226d..3bd8c0b895 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -37,6 +37,7 @@ class CustomEmoji < ApplicationRecord belongs_to :category, class_name: 'CustomEmojiCategory', optional: true has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode + has_many :emoji_reactions, inverse_of: :custom_emoji, dependent: :destroy has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce +profile "!icc,*" +set modify-date +set create-date' } }, validate_media_type: false diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb index 30b83e61ec..4dbeab1946 100644 --- a/app/models/emoji_reaction.rb +++ b/app/models/emoji_reaction.rb @@ -21,7 +21,7 @@ class EmojiReaction < ApplicationRecord belongs_to :account, inverse_of: :emoji_reactions belongs_to :status, inverse_of: :emoji_reactions - belongs_to :custom_emojis, optional: true + belongs_to :custom_emoji, optional: true has_one :notification, as: :activity, dependent: :destroy diff --git a/app/models/notification.rb b/app/models/notification.rb index 3eaf557b08..88c5f44b6c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -60,6 +60,7 @@ class Notification < ApplicationRecord belongs_to :follow, foreign_key: 'activity_id', optional: true belongs_to :follow_request, foreign_key: 'activity_id', optional: true belongs_to :favourite, foreign_key: 'activity_id', optional: true + belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true belongs_to :poll, foreign_key: 'activity_id', optional: true belongs_to :report, foreign_key: 'activity_id', optional: true @@ -79,6 +80,8 @@ class Notification < ApplicationRecord status&.reblog when :favourite favourite&.status + when :emoji_reaction + emoji_reaction&.status when :mention mention&.status when :poll @@ -128,6 +131,8 @@ class Notification < ApplicationRecord notification.status.reblog = cached_status when :favourite notification.favourite.status = cached_status + when :emoji_reaction + notification.emoji_reaction.status = cached_status when :mention notification.mention.status = cached_status when :poll @@ -148,7 +153,7 @@ class Notification < ApplicationRecord return unless new_record? case activity_type - when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report' + when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'FollowRequest', 'Poll', 'Report' self.from_account_id = activity&.account_id when 'Mention' self.from_account_id = activity&.status&.account_id diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index f3d0ffdbae..694997788d 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -27,6 +27,10 @@ class StatusPolicy < ApplicationPolicy show? && !blocking_author? end + def emoji_reaction? + show? && !blocking_author? + end + def destroy? owned? end diff --git a/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb b/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb index c6d21cdaf6..d4230de61a 100644 --- a/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb +++ b/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class REST::EmojiReactionsGroupedByNameSerializer < ActiveModel::Serializer + include RoutingHelper + attributes :name, :count attribute :me, if: :current_user? @@ -14,10 +16,22 @@ class REST::EmojiReactionsGroupedByNameSerializer < ActiveModel::Serializer end def custom_emoji? - object.respond_to?(:custom_emoji) + object.custom_emoji.present? end def account_ids? object.respond_to?(:account_ids) end + + def url + full_asset_url(object.custom_emoji.image.url) + end + + def static_url + full_asset_url(object.custom_emoji.image.url(:static)) + end + + def domain + object.custom_emoji.domain + end end diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb new file mode 100644 index 0000000000..617edd44cc --- /dev/null +++ b/app/services/emoji_react_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class EmojiReactService < BaseService + include Authorization + include Payloadable + + # React a status with emoji and notify remote user + # @param [Account] account + # @param [Status] status + # @param [string] name + # @return [Favourite] + def call(account, status, name) + authorize_with account, status, :emoji_reaction? + + emoji_reaction = EmojiReaction.find_by(account: account, status: status, name: name) + + return emoji_reaction unless emoji_reaction.nil? + + shortcode, domain = name.split('@') + + custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) + + emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji) + + Trends.statuses.register(status) + + create_notification(emoji_reaction) + notify_to_followers(emoji_reaction) + bump_potential_friendship(account, status) + + emoji_reaction + end + + private + + def create_notification(emoji_reaction) + status = emoji_reaction.status + + if status.account.local? + # TODO: Change favourite event to notify + LocalNotificationWorker.perform_async(status.account_id, emoji_reaction.id, 'Favourite', 'favourite') + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), emoji_reaction.account_id, status.account.inbox_url) + end + end + + def notify_to_followers(emoji_reaction) + status = emoji_reaction.status + + return unless status.account.local? + + ActivityPub::RawDistributionWorker.perform_async(emoji_reaction, status.account_id) + end + + def broadcast_updates!(emoji_reaction) + status = emoji_reaction.status + + DistributionWorker.perform_async(status.id, { 'update' => true }) + end + + def bump_potential_friendship(account, status) + ActivityTracker.increment('activity:interactions') + return if account.following?(status.account_id) + + PotentialFriendshipTracker.record(account.id, status.account_id, :emoji_reaction) + end + + def build_json(emoji_reaction) + # TODO: change to original serializer for other servers + Oj.dump(serialize_payload(emoji_reaction, ActivityPub::LikeSerializer)) + end +end diff --git a/config/routes.rb b/config/routes.rb index e7c4c000ee..a9c6e96ed7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -458,6 +458,9 @@ Rails.application.routes.draw do resource :source, only: :show post :translate, to: 'translations#create' + + resources :emoji_reactions, only: :update, constraints: { id: /[^\/]+/ } + post :emoji_unreaction, to: 'emoji_reactions#destroy' end member do