From 1c028a20ac11a07dd763a7bc43ef3f4e80c453d2 Mon Sep 17 00:00:00 2001 From: KMY Date: Sun, 26 Feb 2023 09:45:22 +0900 Subject: [PATCH] Create new stream event type: emoji_reaction --- .../mastodon/actions/interactions.js | 6 ++-- .../mastodon/actions/notifications.js | 10 ++++++ app/javascript/mastodon/actions/statuses.js | 7 ++++ app/javascript/mastodon/actions/streaming.js | 5 ++- .../components/status_emoji_reactions_bar.jsx | 1 - app/javascript/mastodon/locales/en.json | 1 + app/javascript/mastodon/locales/ja.json | 1 + app/javascript/mastodon/reducers/statuses.js | 29 ++++++++++++++- .../styles/mastodon/components.scss | 3 +- app/lib/inline_renderer.rb | 2 ++ .../activitypub/emoji_reaction_serializer.rb | 35 +++++++++++++++++++ .../undo_emoji_reaction_serializer.rb | 23 ++++++++++++ .../rest/emoji_reaction_serializer.rb | 18 ++++++++++ app/services/emoji_react_service.rb | 20 ++++++++--- app/services/un_emoji_react_service.rb | 25 +++++++++++-- 15 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 app/serializers/activitypub/emoji_reaction_serializer.rb create mode 100644 app/serializers/activitypub/undo_emoji_reaction_serializer.rb create mode 100644 app/serializers/rest/emoji_reaction_serializer.rb diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 788259ce63..5b1163b3ab 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -207,8 +207,7 @@ export function emojiReact(status, emoji) { return function (dispatch, getState) { dispatch(emojiReactRequest(status, emoji)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native }).then(function (response) { - dispatch(importFetchedStatus(response.data)); + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native }).then(function () { dispatch(emojiReactSuccess(status, emoji)); }).catch(function (error) { dispatch(emojiReactFail(status, emoji, error)); @@ -220,8 +219,7 @@ export function unEmojiReact(status, emoji) { return (dispatch, getState) => { dispatch(unEmojiReactRequest(status, emoji)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then(response => { - dispatch(importFetchedStatus(response.data)); + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then(() => { dispatch(unEmojiReactSuccess(status, emoji)); }).catch(error => { dispatch(unEmojiReactFail(status, emoji, error)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 93588d3c0c..cd9c1fd1a0 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -15,6 +15,7 @@ import { unescapeHTML } from '../utils/html'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; import { requestNotificationPermission } from '../utils/notifications'; +import { STATUS_EMOJI_REACTION_UPDATE } from './statuses'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -54,6 +55,15 @@ export const loadPending = () => ({ type: NOTIFICATIONS_LOAD_PENDING, }); +export function updateEmojiReactions(emoji_reaction, accountId) { + return (dispatch) => + dispatch({ + type: STATUS_EMOJI_REACTION_UPDATE, + emoji_reaction, + accountId, + }); +} + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 275280a537..1d243ea1ec 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -39,6 +39,8 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; +export const STATUS_EMOJI_REACTION_UPDATE = 'STATUS_EMOJI_REACTION_UPDATE'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -347,3 +349,8 @@ export const undoStatusTranslation = id => ({ type: STATUS_TRANSLATE_UNDO, id, }); + +export const updateEmojiReaction = (emoji_reaction) => ({ + type: STATUS_EMOJI_REACTION_UPDATE, + emoji_reaction, +}); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 84709083fa..c5675abeee 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -12,7 +12,7 @@ import { fillCommunityTimelineGaps, fillListTimelineGaps, } from './timelines'; -import { updateNotifications, expandNotifications } from './notifications'; +import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications'; import { updateConversations } from './conversations'; import { updateStatus } from './statuses'; import { @@ -93,6 +93,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'emoji_reaction': + dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me']))); + break; case 'conversation': dispatch(updateConversations(JSON.parse(data.payload))); break; diff --git a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx index 7fd99bf312..ce86040549 100644 --- a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx +++ b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx @@ -14,7 +14,6 @@ class EmojiReactionButton extends React.PureComponent { staticUrl: PropTypes.string, count: PropTypes.number.isRequired, me: PropTypes.bool, - status: PropTypes.map, onEmojiReact: PropTypes.func, onUnEmojiReact: PropTypes.func, }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ae7722635d..ae2d40f062 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -396,6 +396,7 @@ "not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.", "notification.admin.report": "{name} reported {target}", "notification.admin.sign_up": "{name} signed up", + "notification.emoji_reaction": "{name} reacted your post with emoji", "notification.favourite": "{name} favourited your post", "notification.follow": "{name} followed you", "notification.follow_request": "{name} has requested to follow you", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index ff50491ca0..12a35c22ba 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -396,6 +396,7 @@ "not_signed_in_indicator.not_signed_in": "この機能を使うにはログインする必要があります。", "notification.admin.report": "{name}さんが{target}さんを通報しました", "notification.admin.sign_up": "{name}さんがサインアップしました", + "notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました", "notification.favourite": "{name}さんがあなたの投稿をお気に入りに登録しました", "notification.follow": "{name}さんにフォローされました", "notification.follow_request": "{name}さんがあなたにフォローリクエストしました", diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index a39ac6b649..fcb80ae841 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -17,10 +17,11 @@ import { STATUS_TRANSLATE_UNDO, STATUS_FETCH_REQUEST, STATUS_FETCH_FAIL, + STATUS_EMOJI_REACTION_UPDATE, } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; -import { Map as ImmutableMap, fromJS } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const importStatus = (state, status) => state.set(status.id, fromJS(status)); @@ -35,6 +36,30 @@ const deleteStatus = (state, id, references) => { return state.delete(id); }; +const updateStatusEmojiReaction = (state, emoji_reaction, myId) => { + emoji_reaction.me = emoji_reaction.account_ids ? emoji_reaction.account_ids.indexOf(myId) >= 0 : false; + + const status = state.get(emoji_reaction.status_id); + if (!status) return state; + + let emoji_reactions = Array.from(status.get('emoji_reactions') || []); + + if (emoji_reaction.count > 0) { + const old_emoji = emoji_reactions.find((er) => er.name === emoji_reaction.name && er.url === emoji_reaction.url); + if (old_emoji) { + old_emoji.account_ids = emoji_reaction.account_ids; + old_emoji.count = emoji_reaction.count; + old_emoji.me = emoji_reaction.me; + } else { + emoji_reactions.push(ImmutableMap(emoji_reaction)); + } + } else { + emoji_reactions = emoji_reactions.filter((er) => er.get('name') !== emoji_reaction.name || er.get('domain') !== emoji_reaction.domain); + } + + return state.setIn([emoji_reaction.status_id, 'emoji_reactions'], ImmutableList(emoji_reactions)); +}; + const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { @@ -89,6 +114,8 @@ export default function statuses(state = initialState, action) { return state.setIn([action.id, 'translation'], fromJS(action.translation)); case STATUS_TRANSLATE_UNDO: return state.deleteIn([action.id, 'translation']); + case STATUS_EMOJI_REACTION_UPDATE: + return updateStatusEmojiReaction(state, action.emoji_reaction, action.accountId); default: return state; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 74341891d1..b3dfbf4712 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1264,7 +1264,8 @@ body > [data-popper-placement] { .status__emoji-reactions-bar { display: flex; - gap: 8px; + flex-wrap: wrap; + gap: 4px 8px; margin: 8px 0 2px 4px; .emoji-reactions-bar__button { diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 4bb240b48b..7a9f0e7f1d 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -14,6 +14,8 @@ class InlineRenderer preload_associations_for_status when :notification serializer = REST::NotificationSerializer + when :emoji_reaction + serializer = REST::EmojiReactionSerializer when :conversation serializer = REST::ConversationSerializer when :announcement diff --git a/app/serializers/activitypub/emoji_reaction_serializer.rb b/app/serializers/activitypub/emoji_reaction_serializer.rb new file mode 100644 index 0000000000..15017f6451 --- /dev/null +++ b/app/serializers/activitypub/emoji_reaction_serializer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer + attributes :id, :type, :actor, :content + attribute :virtual_object, key: :object + + has_many :virtual_tags, key: :tag, unless: -> { object.custom_emoji.nil? } + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join + end + + def type + 'Like' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def virtual_object + ActivityPub::TagManager.instance.uri_for(object.status) + end + + def content + object.custom_emoji.nil? ? object.name : ":#{object.name}:" + end + + def virtual_tags + [object.custom_emoji] + end + + class CustomEmojiSerializer < ActivityPub::EmojiSerializer + end +end diff --git a/app/serializers/activitypub/undo_emoji_reaction_serializer.rb b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb new file mode 100644 index 0000000000..7bba3c88b8 --- /dev/null +++ b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer + attributes :id, :type, :actor, :content + + has_one :object, serializer: ActivityPub::EmojiReactionSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id, '/undo'].join + end + + def type + 'Undo' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def content + object.custom_emoji.nil? ? object.name : ":#{object.name}:" + end +end diff --git a/app/serializers/rest/emoji_reaction_serializer.rb b/app/serializers/rest/emoji_reaction_serializer.rb new file mode 100644 index 0000000000..1d8f903685 --- /dev/null +++ b/app/serializers/rest/emoji_reaction_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class REST::EmojiReactionSerializer < ActiveModel::Serializer + attributes :name, :count + + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + attribute :account_ids, if: :account_ids? + + def custom_emoji? + object.url.present? + end + + def account_ids? + object.respond_to?(:account_ids) + end +end diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb index 6a5e3167d9..0f45d9d86b 100644 --- a/app/services/emoji_react_service.rb +++ b/app/services/emoji_react_service.rb @@ -3,6 +3,7 @@ class EmojiReactService < BaseService include Authorization include Payloadable + include Redisable # React a status with emoji and notify remote user # @param [Account] account @@ -27,6 +28,7 @@ class EmojiReactService < BaseService create_notification(emoji_reaction) notify_to_followers(emoji_reaction) bump_potential_friendship(account, status) + write_stream(emoji_reaction) emoji_reaction end @@ -37,8 +39,7 @@ class EmojiReactService < BaseService 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') + LocalNotificationWorker.perform_async(status.account_id, emoji_reaction.id, 'EmojiReaction', 'emoji_reaction') elsif status.account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), emoji_reaction.account_id, status.account.inbox_url) end @@ -58,6 +59,13 @@ class EmojiReactService < BaseService DistributionWorker.perform_async(status.id, { 'update' => true }) end + def write_stream(emoji_reaction) + emoji_group = emoji_reaction.status.emoji_reactions_grouped_by_name + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.domain) } + emoji_group['status_id'] = emoji_reaction.status_id.to_s + redis.publish("timeline:#{emoji_reaction.status.account_id}", render_emoji_reaction(emoji_group)) + end + def bump_potential_friendship(account, status) ActivityTracker.increment('activity:interactions') return if account.following?(status.account_id) @@ -66,7 +74,11 @@ class EmojiReactService < BaseService end def build_json(emoji_reaction) - # TODO: change to original serializer for other servers - Oj.dump(serialize_payload(emoji_reaction, ActivityPub::LikeSerializer)) + Oj.dump(serialize_payload(emoji_reaction, ActivityPub::EmojiReactionSerializer)) + end + + def render_emoji_reaction(emoji_group) + # @rendered_emoji_reaction ||= InlineRenderer.render(HashObject.new(emoji_group), nil, :emoji_reaction) + @render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) end end diff --git a/app/services/un_emoji_react_service.rb b/app/services/un_emoji_react_service.rb index 24f7c83b26..384eacefbd 100644 --- a/app/services/un_emoji_react_service.rb +++ b/app/services/un_emoji_react_service.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true class UnEmojiReactService < BaseService + include Redisable include Payloadable def call(account, status, emoji_reaction = nil) if emoji_reaction - emoji_reaction.destroy! + emoji_reaction.destroy create_notification(emoji_reaction) if !status.account.local? && status.account.activitypub? notify_to_followers(emoji_reaction) if status.account.local? + write_stream(emoji_reaction) else bulk(account, status) end @@ -32,8 +34,25 @@ class UnEmojiReactService < BaseService ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), status.account_id) end + def write_stream(emoji_reaction) + emoji_group = emoji_reaction.status.emoji_reactions_grouped_by_name + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.domain) } + if emoji_group + emoji_group['status_id'] = emoji_reaction.status_id.to_s + else + # name: emoji_reaction.name, count: 0, domain: emoji_reaction.domain + emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => emoji_reaction.status_id.to_s } + emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji + end + redis.publish("timeline:#{emoji_reaction.status.account_id}", render_emoji_reaction(emoji_reaction, emoji_group)) + end + def build_json(emoji_reaction) - # TODO: change to original serializer for other servers - Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoLikeSerializer)) + Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer)) + end + + def render_emoji_reaction(_emoji_reaction, emoji_group) + # @rendered_emoji_reaction ||= InlineRenderer.render(emoji_group, nil, :emoji_reaction) + Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) end end