Add api for set emoji reactions to toot

This commit is contained in:
KMY
2023-02-24 20:57:01 +09:00
committed by YoheiZuho
parent 53e4c6adff
commit ba989d9db6
17 changed files with 310 additions and 18 deletions

View File

@@ -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

View File

@@ -15,6 +15,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';
@@ -23,6 +27,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';
@@ -205,6 +213,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));

View File

@@ -167,6 +167,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,

View File

@@ -87,6 +87,7 @@ class StatusActionBar extends ImmutablePureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onEmojiReact: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
@@ -149,6 +150,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;
@@ -264,16 +275,6 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
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;

View File

@@ -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);
@@ -46,16 +46,16 @@ class EmojiReactionButton extends React.PureComponent {
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) => (
<EmojiReactionButton
key={emoji.get('id')}
key={index}
name={emoji.get('name')}
count={emoji.get('count')}
me={emoji.get('me')}

View File

@@ -25,9 +25,11 @@ import {
import {
reblog,
favourite,
emojiReact,
bookmark,
unreblog,
unfavourite,
unEmojiReact,
unbookmark,
pin,
unpin,
@@ -140,6 +142,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onEmojiReact (status, emoji) {
dispatch(emojiReact(status, emoji));
},
onUnEmojiReact (status, emoji) {
dispatch(unEmojiReact(status, emoji));
},
onBookmark (status) {
if (status.get('bookmarked')) {
dispatch(unbookmark(status));

View File

@@ -62,6 +62,10 @@ const mapDispatchToProps = dispatch => ({
}
},
onEmojiReact (status, emoji) {
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));

View File

@@ -100,6 +100,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onEmojiReact (status, emoji) {
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));

View File

@@ -0,0 +1,32 @@
# frozen_string_literal: true
class PotentialFriendshipTracker
EXPIRE_AFTER = 90.days.seconds
MAX_ITEMS = 80
WEIGHTS = {
reply: 1,
emoji_reaction: 5,
favourite: 10,
reblog: 20,
}.freeze
class << self
include Redisable
def record(account_id, target_account_id, action)
return if account_id == target_account_id
key = "interactions:#{account_id}"
weight = WEIGHTS[action]
redis.zincrby(key, weight, target_account_id)
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
redis.expire(key, EXPIRE_AFTER)
end
def remove(account_id, target_account_id)
redis.zrem("interactions:#{account_id}", target_account_id)
end
end
end

View File

@@ -13,6 +13,7 @@ module Account::Associations
# 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

View File

@@ -37,6 +37,9 @@ 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_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode, inverse_of: false, dependent: nil
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' } }, validate_media_type: false

View File

@@ -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

View File

@@ -61,6 +61,8 @@ class Notification < ApplicationRecord
belongs_to :follow, inverse_of: :notification
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :emoji_reaction, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
end
@@ -81,6 +83,8 @@ class Notification < ApplicationRecord
status&.reblog
when :favourite
favourite&.status
when :emoji_reaction
emoji_reaction&.status
when :mention
mention&.status
when :poll
@@ -130,6 +134,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
@@ -150,7 +156,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

View File

@@ -27,6 +27,10 @@ class StatusPolicy < ApplicationPolicy
show? && !blocking_author?
end
def emoji_reaction?
show? && !blocking_author?
end
def destroy?
owned?
end

View File

@@ -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

View File

@@ -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

View File

@@ -29,6 +29,9 @@ namespace :api, format: false 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