diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 98fa1897e..20e1c8223 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Api::BaseController < ApplicationController - DEFAULT_STATUSES_LIMIT = 20 - DEFAULT_ACCOUNTS_LIMIT = 40 + DEFAULT_STATUSES_LIMIT = 20 + DEFAULT_ACCOUNTS_LIMIT = 40 include Api::RateLimitHeaders include Api::AccessTokenTrackingConcern diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb new file mode 100644 index 000000000..03817c682 --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::EmojiReactionedByAccountsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::EmojiReactionAccountSerializer + end + + private + + def load_accounts + scope = default_accounts + # scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_emoji_reactions).to_a + end + + def default_accounts + EmojiReaction + .where(status_id: @status.id) + #.where(account: { suspended_at: nil }) + end + + def paginated_emoji_reactions + EmojiReaction.paginate_by_max_id( + limit_param(1000), #limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_emoji_reactioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_emoji_reactioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb new file mode 100644 index 000000000..222017452 --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::EmojiReactionedByAccountsSlimController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + + def index + @accounts = load_emoji_reactions + + # TODO for serialize hash object + render json: @accounts, each_serializer: REST::EmojiReactedBySlimSerializer + end + + private + + def load_emoji_reactions + @status.generate_emoji_reactions_grouped_by_account + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end 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 000000000..1b56033bc --- /dev/null +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -0,0 +1,64 @@ +# 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: %i(create update destroy) + before_action :set_status_without_authorize, only: [:destroy] + + def create + create_private(params[:emoji]) + end + + # For compatible with Fedibird API + def update + create_private(params[:id]) + end + + def destroy + emoji = params[:emoji] || params[:id] + + if emoji + shortcode, domain = emoji.split('@') + emoji_reaction = EmojiReaction.where(account_id: current_account.id).where(status_id: @status.id).where(name: shortcode) + .find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain } + + authorize @status, :show? if emoji_reaction.nil? + end + + UnEmojiReactWorker.perform_async(current_account.id, @status.id, emoji) + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new( + [@status], current_account.id, emoji_reactions_map: { @status.id => false } + ) + rescue Mastodon::NotPermittedError + not_found + end + + private + + def create_private(emoji) + count = EmojiReaction.where(account: current_account, status: @status).count + + if count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT + bad_request + return + end + + EmojiReactService.new.call(current_account, @status, emoji) + render json: @status, serializer: REST::StatusSerializer + end + + def set_status + set_status_without_authorize + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end + + def set_status_without_authorize + @status = Status.find(params[:status_id]) + end +end diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 0b1f024a4..f652f4c92 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -40,6 +40,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.filtered = status.filtered.map(normalizeFilterResult); } + if (status.emoji_reactions) { + normalStatus.emojiReactions = status.emoji_reactions; + } + // Only calculate these values when status first encountered and // when the underlying values change. Otherwise keep the ones // already in the reducer diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 7d0144438..d34eb5c8c 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -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'; @@ -33,7 +41,11 @@ export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST'; export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS'; -export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; +export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL'; + +export const EMOJI_REACTIONS_FETCH_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST'; +export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS'; +export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL'; export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_SUCCESS = 'PIN_SUCCESS'; @@ -205,6 +217,88 @@ export function unfavouriteFail(status, error) { }; } +export function emojiReact(status, emoji) { + return function (dispatch, getState) { + dispatch(emojiReactRequest(status, emoji)); + + const api_emoji = typeof emoji !== 'string' ? (emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native) : emoji; + + api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function () { + 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_unreaction`, { emoji }).then(() => { + 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)); @@ -441,6 +535,85 @@ export function expandFavouritesFail(id, error) { }; } +export function fetchEmojiReactions(id) { + return (dispatch, getState) => { + dispatch(fetchEmojiReactionsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/emoji_reactioned_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map((er) => er.account))); + dispatch(fetchEmojiReactionsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEmojiReactionsFail(id, error)); + }); + }; +} + +export function fetchEmojiReactionsRequest(id) { + return { + type: EMOJI_REACTIONS_FETCH_REQUEST, + id, + }; +} + +export function fetchEmojiReactionsSuccess(id, accounts, next) { + return { + type: EMOJI_REACTIONS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchEmojiReactionsFail(id, error) { + return { + type: EMOJI_REACTIONS_FETCH_FAIL, + error, + }; +} + +export function expandEmojiReactions(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'emoji_reactioned_by', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandEmojiReactionsRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.map((er) => er.account))); + dispatch(expandEmojiReactionsSuccess(id, response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandEmojiReactionsFail(id, error))); + }; +} + +export function expandEmojiReactionsRequest(id) { + return { + type: EMOJI_REACTIONS_EXPAND_REQUEST, + id, + }; +} + +export function expandEmojiReactionsSuccess(id, accounts, next) { + return { + type: EMOJI_REACTIONS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandEmojiReactionsFail(id, error) { + return { + type: EMOJI_REACTIONS_EXPAND_FAIL, + id, + error, + }; +} + export function pin(status) { return (dispatch, getState) => { dispatch(pinRequest(status)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index eafbf42d1..2674405d2 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -21,6 +21,7 @@ import { submitMarkers } from './markers'; import { notificationsUpdate } from "./notifications_typed"; import { register as registerPushNotifications } from './push_notifications'; import { saveSettings } from './settings'; +import { STATUS_EMOJI_REACTION_UPDATE } from './statuses'; export * from "./notifications_typed"; @@ -61,6 +62,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']); @@ -130,10 +140,13 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'emoji_reaction', 'reblog', + 'status_reference', 'mention', 'poll', 'status', + 'list_status', 'update', 'admin.sign_up', 'admin.report', diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index f76303335..7b34ba1c6 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -42,6 +42,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, @@ -372,4 +374,9 @@ export function revealQuote(ids) { type: QUOTE_REVEAL, ids, }; -} +}; + +export const updateEmojiReaction = (emoji_reaction) => ({ + type: STATUS_EMOJI_REACTION_UPDATE, + emoji_reaction, +}); \ No newline at end of file diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 9daeb3c60..89e71c893 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,7 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { updateNotifications, expandNotifications } from './notifications'; +import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, @@ -24,6 +24,8 @@ import { fillListTimelineGaps, } from './timelines'; +const { messages } = getLocale(); + /** * @param {number} max * @returns {number} @@ -102,6 +104,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti // @ts-expect-error dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'emoji_reaction': + // @ts-expect-error + dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me']))); + break; case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index 4a99dd0bb..7e0f915b7 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -46,6 +46,8 @@ class Account extends ImmutablePureComponent { minimal: PropTypes.bool, defaultAction: PropTypes.string, withBio: PropTypes.bool, + onActionClick: PropTypes.func, + children: PropTypes.object, }; static defaultProps = { @@ -73,7 +75,7 @@ class Account extends ImmutablePureComponent { }; render () { - const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props; + const { account, intl, hidden, onActionClick, actionIcon, actionTitle, withBio, defaultAction, size, minimal, children } = this.props; if (!account) { return ; @@ -156,11 +158,13 @@ class Account extends ImmutablePureComponent { - {!minimal && ( -
- {buttons} -
- )} +
+ {children} +
+ +
+ {buttons} +
{withBio && (account.get('note').length > 0 ? ( diff --git a/app/javascript/mastodon/components/emoji_view.jsx b/app/javascript/mastodon/components/emoji_view.jsx new file mode 100644 index 000000000..ac6e92a2e --- /dev/null +++ b/app/javascript/mastodon/components/emoji_view.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import emojify from '../features/emoji/emoji'; +import classNames from 'classnames'; + +export default class EmojiView extends React.PureComponent { + + static propTypes = { + name: PropTypes.string, + url: PropTypes.string, + staticUrl: PropTypes.string, + }; + + render () { + const { name, url, staticUrl } = this.props; + + let emojiHtml = null; + if (url) { + let customEmojis = {}; + customEmojis[`:${name}:`] = { url, static_url: staticUrl }; + emojiHtml = emojify(`:${name}:`, customEmojis); + } else { + emojiHtml = emojify(name); + } + + return ( + + ); + } + +} diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 65441b7b9..045b4b283 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -1,5 +1,8 @@ import PropTypes from 'prop-types'; +import StatusEmojiReactionsBar from './status_emoji_reactions_bar'; +import AttachmentList from './attachment_list'; + import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; @@ -163,6 +166,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, @@ -198,6 +203,7 @@ class Status extends ImmutablePureComponent { }), contextType: PropTypes.string, ...WithOptionalRouterPropTypes, + withoutEmojiReactions: PropTypes.bool, }; // Avoid checking props that are functions (and whose equality will always @@ -677,6 +683,12 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; + let emojiReactionsBar = null; + if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) { + const emojiReactions = status.get('emoji_reactions'); + emojiReactionsBar = ; + } + return (
@@ -720,6 +732,8 @@ class Status extends ImmutablePureComponent { {expanded && hashtagBar} + {emojiReactionsBar} +
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 0c2408c39..90d8205a7 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; +import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; import { defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; @@ -51,6 +52,7 @@ const messages = defineMessages({ quote: { id: 'status.quote', defaultMessage: 'Quote' }, cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, + emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Emoji Reaction' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, @@ -88,6 +90,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, @@ -150,6 +153,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; @@ -403,6 +416,10 @@ class StatusActionBar extends ImmutablePureComponent { const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); + const emojiPickerButton = ( + + ); + return (
@@ -410,6 +427,7 @@ class StatusActionBar extends ImmutablePureComponent { + {filterButton} diff --git a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx new file mode 100644 index 000000000..603a217c5 --- /dev/null +++ b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import emojify from '../features/emoji/emoji'; +import classNames from 'classnames'; +import EmojiView from './emoji_view'; + +class EmojiReactionButton extends React.PureComponent { + + static propTypes = { + name: PropTypes.string, + domain: PropTypes.string, + url: PropTypes.string, + staticUrl: PropTypes.string, + count: PropTypes.number.isRequired, + me: PropTypes.bool, + onEmojiReact: PropTypes.func, + onUnEmojiReact: PropTypes.func, + }; + + onClick = () => { + const { name, domain, me } = this.props; + + const nameParameter = domain ? `${name}@${domain}` : name; + if (me) { + if (this.props.onUnEmojiReact) this.props.onUnEmojiReact(nameParameter); + } else { + if (this.props.onEmojiReact) this.props.onEmojiReact(nameParameter); + } + }; + + render () { + const { name, url, staticUrl, count, me } = this.props; + + const classList = { + 'reactions-bar__item': true, + 'active': me, + }; + + return ( + + ); + } + +} + +class StatusEmojiReactionsBar extends React.PureComponent { + + static propTypes = { + emojiReactions: ImmutablePropTypes.list.isRequired, + status: ImmutablePropTypes.map, + onEmojiReact: PropTypes.func, + onUnEmojiReact: PropTypes.func, + }; + + onEmojiReact = (name) => { + if (!this.props.onEmojiReact) return; + this.props.onEmojiReact(this.props.status, name); + }; + + onUnEmojiReact = (name) => { + if (!this.props.onUnEmojiReact) return; + this.props.onUnEmojiReact(this.props.status, name); + }; + + render () { + const { emojiReactions } = this.props; + + const emojiButtons = Array.from(emojiReactions).map((emoji, index) => ( + )); + + return ( +
+ {emojiButtons} +
+ ); + } + +} +export default injectIntl(StatusEmojiReactionsBar); \ No newline at end of file diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index fea108c23..8c239038f 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -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)); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index 37017f4cc..bb2d6a46b 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -330,6 +330,7 @@ class EmojiPickerDropdown extends PureComponent { state = { active: false, loading: false, + bottom: true, }; setRef = (c) => { @@ -337,6 +338,7 @@ class EmojiPickerDropdown extends PureComponent { }; onShowDropdown = () => { + this.updateDropdownPosition(); this.setState({ active: true }); if (!EmojiPicker) { @@ -357,6 +359,23 @@ class EmojiPickerDropdown extends PureComponent { this.setState({ active: false }); }; + updateDropdownPosition = () => { + let bottom = true; + + if (this.target) { + const height = window.innerHeight; + const rect = this.target.getBoundingClientRect(); + + if (height && rect) { + bottom = height / 2 > rect.top; + } + } + + if (this.state.bottom !== bottom) { + this.setState({ bottom }); + } + }; + onToggle = (e) => { if (!this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { @@ -384,7 +403,7 @@ class EmojiPickerDropdown extends PureComponent { render () { const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; const title = intl.formatMessage(messages.emoji); - const { active, loading } = this.state; + const { active, loading, bottom } = this.state; return (
@@ -397,7 +416,7 @@ class EmojiPickerDropdown extends PureComponent { inverted /> - + {({ props, placement })=> (
diff --git a/app/javascript/mastodon/features/emoji_reactions/index.jsx b/app/javascript/mastodon/features/emoji_reactions/index.jsx new file mode 100644 index 000000000..4f5c3987f --- /dev/null +++ b/app/javascript/mastodon/features/emoji_reactions/index.jsx @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { ReactComponent as RefreshIcon } from '@material-symbols/svg-600/outlined/refresh.svg'; +import { debounce } from 'lodash'; + +import { fetchEmojiReactions, expandEmojiReactions } from 'mastodon/actions/interactions'; +import ColumnHeader from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import AccountContainer from 'mastodon/containers/account_container'; +import Column from 'mastodon/features/ui/components/column'; + + + +import EmojiView from '../../components/emoji_view'; +import { LoadingIndicator } from '../../components/loading_indicator'; + +const messages = defineMessages({ + refresh: { id: 'refresh', defaultMessage: 'Refresh' }, +}); + +const mapStateToProps = (state, props) => { + return { + accountIds: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'items']), + hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'next']), + isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'isLoading'], true), + }; +}; + +class EmojiReactions extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchEmojiReactions(this.props.params.statusId)); + } + } + + handleRefresh = () => { + this.props.dispatch(fetchEmojiReactions(this.props.params.statusId)); + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandEmojiReactions(this.props.params.statusId)); + }, 300, { leading: true }); + + render () { + const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + let groups = {}; + for (const emoji_reaction of accountIds) { + const key = emoji_reaction.account_id; + const value = emoji_reaction; + if (!groups[key]) groups[key] = [value]; + else groups[key].push(value); + } + + const emptyMessage = ; + + return ( + + + )} + /> + + + {Object.keys(groups).map((key) =>( + +
+ {groups[key].map((value, index2) => )} +
+
+ ))} +
+ + + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(EmojiReactions)); \ No newline at end of file diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx index 09154f257..c9b990e62 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx +++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx @@ -119,6 +119,17 @@ export default class ColumnSettings extends PureComponent {
+
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx index c288c2c0d..364b03b7f 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx @@ -5,6 +5,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; +import EmojiReactionIcon from '@material-icons/400-24px/mood.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; @@ -14,6 +15,7 @@ import { Icon } from 'mastodon/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' }, + emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -81,6 +83,13 @@ class FilterBar extends PureComponent { > +
+ + ); + } + + renderEmojiReaction (notification, link) { + const { intl, unread } = this.props; + const emoji_reaction = notification.get('emoji_reaction'); + + return ( + +
+
+
+ +
+ + + + +
+ +
@@ -241,6 +280,7 @@ class Notification extends ImmutablePureComponent { updateScrollBottom={this.props.updateScrollBottom} cachedMediaWidth={this.props.cachedMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth} + withoutEmojiReactions={true} />
@@ -276,6 +316,7 @@ class Notification extends ImmutablePureComponent { updateScrollBottom={this.props.updateScrollBottom} cachedMediaWidth={this.props.cachedMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth} + withoutEmojiReactions={true} />
@@ -311,6 +352,7 @@ class Notification extends ImmutablePureComponent { updateScrollBottom={this.props.updateScrollBottom} cachedMediaWidth={this.props.cachedMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth} + withoutEmojiReactions={true} /> @@ -352,6 +394,7 @@ class Notification extends ImmutablePureComponent { updateScrollBottom={this.props.updateScrollBottom} cachedMediaWidth={this.props.cachedMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth} + withoutEmojiReactions={true} /> @@ -421,6 +464,8 @@ class Notification extends ImmutablePureComponent { return this.renderMention(notification); case 'favourite': return this.renderFavourite(notification, link); + case 'emoji_reaction': + return this.renderEmojiReaction(notification, link); case 'reblog': return this.renderReblog(notification, link); case 'status': diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 4458fd7bc..4b3238b49 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -7,6 +7,7 @@ import { favourite, unreblog, unfavourite, + emojiReact, } from '../../../actions/interactions'; import { hideStatus, @@ -62,6 +63,10 @@ const mapDispatchToProps = dispatch => ({ } }, + onEmojiReact (status, emoji) { + dispatch(emojiReact(status, emoji)); + }, + onToggleHidden (status) { if (status.get('hidden')) { dispatch(revealStatus(status.get('id'))); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 7bc6e7429..055e6836e 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; +import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container'; import { defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; @@ -84,6 +85,7 @@ class ActionBar extends PureComponent { onReblog: PropTypes.func.isRequired, onQuote: PropTypes.func, onFavourite: PropTypes.func.isRequired, + onEmojiReact: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired, @@ -206,6 +208,10 @@ class ActionBar extends PureComponent { navigator.clipboard.writeText(url); }; + handleEmojiPick = (data) => { + this.props.onEmojiReact(this.props.status, data); + }; + render () { const { status, relationship, intl } = this.props; const { signedIn, permissions } = this.context.identity; @@ -327,6 +333,7 @@ class ActionBar extends PureComponent {
+
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index feeed1079..37dd05fe7 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { Link, withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar'; import ImmutablePureComponent from 'react-immutable-pure-component'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; @@ -53,6 +54,8 @@ class DetailedStatus extends ImmutablePureComponent { onToggleMediaVisibility: PropTypes.func, onQuoteToggleMediaVisibility: PropTypes.func, ...WithRouterPropTypes, + onEmojiReact: PropTypes.func, + onUnEmojiReact: PropTypes.func, }; state = { @@ -171,6 +174,7 @@ class DetailedStatus extends ImmutablePureComponent { const reblogIcon = 'retweet'; const reblogIconComponent = RepeatIcon; let favouriteLink = ''; + let emojiReactionsLink = ''; let edited = ''; if (this.props.measureHeight) { @@ -261,9 +265,14 @@ class DetailedStatus extends ImmutablePureComponent { return (); } - } + let emojiReactionsBar = null; + if (status.get('emoji_reactions')) { + const emojiReactions = status.get('emoji_reactions'); + emojiReactionsBar = ; + } + if (status.get('application')) { applicationLink = <> · {status.getIn(['application', 'name'])}; } @@ -318,6 +327,22 @@ class DetailedStatus extends ImmutablePureComponent { ); } + if (this.context.router) { + emojiReactionsLink = ( + + + + + ); + } else { + emojiReactionsLink = ( + + + + + ); + } + if (status.get('edited_at')) { edited = ( <> @@ -355,10 +380,12 @@ class DetailedStatus extends ImmutablePureComponent { {expanded && hashtagBar} + {emojiReactionsBar} +
- {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} + {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
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 44491127f..a472a085c 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -17,6 +17,8 @@ import { unfavourite, pin, unpin, + emojiReact, + unEmojiReact, } from '../../../actions/interactions'; import { openModal } from '../../../actions/modal'; import { initMuteModal } from '../../../actions/mutes'; @@ -100,6 +102,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onEmojiReact (status, emoji) { + dispatch(emojiReact(status, emoji)); + }, + + onUnEmojiReact (status, emoji) { + dispatch(unEmojiReact(status, emoji)); + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index fa66c3c39..b7ba1538a 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -41,6 +41,8 @@ import { import { favourite, unfavourite, + emojiReact, + unEmojiReact, bookmark, unbookmark, reblog, @@ -279,6 +281,16 @@ class Status extends ImmutablePureComponent { } }; + handleEmojiReact = (status, emoji) => { + const { dispatch } = this.props; + dispatch(emojiReact(status, emoji)); + }; + + handleUnEmojiReact = (status, emoji) => { + const { dispatch } = this.props; + dispatch(unEmojiReact(status, emoji)); + }; + handlePin = (status) => { if (status.get('pinned')) { this.props.dispatch(unpin(status)); @@ -749,6 +761,8 @@ class Status extends ImmutablePureComponent { onToggleMediaVisibility={this.handleToggleMediaVisibility} onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility} pictureInPicture={pictureInPicture} + onEmojiReact={this.handleEmojiReact} + onUnEmojiReact={this.handleUnEmojiReact} /> + @@ -224,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent { + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7b968204b..2dba7de7d 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -82,6 +82,10 @@ export function Favourites () { return import(/* webpackChunkName: "features/favourites" */'../../favourites'); } +export function EmojiReactions () { + return import(/* webpackChunkName: "features/favourites" */'../../emoji_reactions'); +} + export function FollowRequests () { return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8155ca9ad..9942f30f2 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -428,7 +428,8 @@ "not_signed_in_indicator.not_signed_in": "You need to login to access this resource.", "notification.admin.report": "{name} reported {target}", "notification.admin.sign_up": "{name} signed up", - "notification.favourite": "{name} favorited your post", + "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", "notification.mention": "{name} mentioned you", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 761c7a1d1..480bbffe8 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -428,6 +428,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}さんがあなたのトゥートに╰( ^o^)╮-=ニ=一=三★しました", "notification.follow": "{name}さんにフォローされました", "notification.follow_request": "{name}さんがあなたにフォローリクエストしました", diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 2ca301b19..8673e51ec 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({ id: notification.id, type: notification.type, account: notification.account.id, + emoji_reaction: ImmutableMap(notification.emoji_reaction), created_at: notification.created_at, status: notification.status ? notification.status.id : null, report: notification.report ? fromJS(notification.report) : null, diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index e3cf12965..233a90eed 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { normalizeStatusTranslation } from '../actions/importer/normalizer'; @@ -26,6 +26,7 @@ import { STATUS_TRANSLATE_UNDO, STATUS_FETCH_REQUEST, STATUS_FETCH_FAIL, + STATUS_EMOJI_REACTION_UPDATE, QUOTE_REVEAL, QUOTE_HIDE, } from '../actions/statuses'; @@ -65,6 +66,29 @@ const statusTranslateUndo = (state, 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.get('name') === emoji_reaction.name && (!er.get('domain') || er.get('domain') === emoji_reaction.domain)); + if (old_emoji) { + const index = emoji_reactions.indexOf(old_emoji); + emoji_reactions[index] = old_emoji.merge({ account_ids: emoji_reaction.account_ids, count: emoji_reaction.count, 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) { @@ -137,6 +161,8 @@ export default function statuses(state = initialState, action) { return statusTranslateSuccess(state, action.id, action.translation); case STATUS_TRANSLATE_UNDO: return statusTranslateUndo(state, action.id); + case STATUS_EMOJI_REACTION_UPDATE: + return updateStatusEmojiReaction(state, action.emoji_reaction, action.accountId); default: return state; } diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 2f17fed5f..0b8dc69e9 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -1,5 +1,4 @@ import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; - import { DIRECTORY_FETCH_REQUEST, DIRECTORY_FETCH_SUCCESS, @@ -57,6 +56,7 @@ import { FAVOURITES_EXPAND_REQUEST, FAVOURITES_EXPAND_SUCCESS, FAVOURITES_EXPAND_FAIL, + EMOJI_REACTIONS_FETCH_SUCCESS, } from '../actions/interactions'; import { MUTES_FETCH_REQUEST, @@ -79,6 +79,7 @@ const initialState = ImmutableMap({ following: initialListState, reblogged_by: initialListState, favourited_by: initialListState, + emoji_reactioned_by: initialListState, follow_requests: initialListState, blocks: initialListState, mutes: initialListState, @@ -161,6 +162,8 @@ export default function userLists(state = initialState, action) { return state.setIn(['favourited_by', action.id, 'isLoading'], false); case notificationsUpdate.type: return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state; + case EMOJI_REACTIONS_FETCH_SUCCESS: + return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts)); case FOLLOW_REQUESTS_FETCH_SUCCESS: return normalizeList(state, ['follow_requests'], action.accounts, action.next); case FOLLOW_REQUESTS_EXPAND_SUCCESS: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 998a0b548..4b985d253 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1725,13 +1725,50 @@ body > [data-popper-placement] { color: $highlight-text-color; } } +.status__emoji-reactions-bar { + display: flex; + flex-wrap: wrap; + // gap: 4px 8px; + margin: 8px 0 2px 0; + + .emoji-reactions-bar__button { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 3px; + padding: 0 6px; + cursor: pointer; + display: flex; + justify-items: center; + align-items: center; + height: 20px; + + &.toggled { + background-color: mix($white, $ui-highlight-color, 80%); + border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%); + } + + .emoji { + display: block; + height: 16px; + img { + margin-top: 0; + margin-bottom: 0; + } + } + .count { + display: block; + margin: 0 2px; + color: $ui-secondary-color; + } + } +} .status__action-bar { display: flex; justify-content: space-between; align-items: center; gap: 18px; - margin-top: 16px; + //margin-top: 16px; } .detailed-status__action-bar-dropdown { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 322f3e27a..96a0836aa 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -37,7 +37,7 @@ class ActivityPub::Activity ActivityPub::Activity::Delete when 'Follow' ActivityPub::Activity::Follow - when 'Like' + when 'Like', 'EmojiReaction', 'EmojiReact' ActivityPub::Activity::Like when 'Block' ActivityPub::Activity::Block diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index aa1dc3040..93e5d7dcc 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -2,13 +2,102 @@ class ActivityPub::Activity::Like < ActivityPub::Activity def perform - original_status = status_from_uri(object_uri) + @original_status = status_from_uri(object_uri) - return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) + return if @original_status.nil? || !@original_status.account.local? || delete_arrived_first?(@json['id']) - favourite = original_status.favourites.create!(account: @account) - - LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite') - Trends.statuses.register(original_status) + if shortcode.nil? + process_favourite + else + process_emoji_reaction + end end -end + + private + + def process_favourite + return if @account.favourited?(@original_status) + + favourite = @original_status.favourites.create!(account: @account) + + LocalNotificationWorker.perform_async(@original_status.account_id, favourite.id, 'Favourite', 'favourite') + Trends.statuses.register(@original_status) + end + + def process_emoji_reaction + if emoji_tag.present? + return if emoji_tag['id'].blank? || emoji_tag['name'].blank? || emoji_tag['icon'].blank? || emoji_tag['icon']['url'].blank? + + image_url = emoji_tag['icon']['url'] + uri = emoji_tag['id'] + domain = URI.split(uri)[2] + + emoji = CustomEmoji.find_or_create_by!(shortcode: shortcode, domain: domain) do |emoji| + emoji.uri = uri + emoji.image_remote_url = image_url + end + end + + return if EmojiReaction.where(account: @account, status: @original_status).count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT + + reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id']) + write_stream(reaction) + + if @original_status.account.local? + NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) + forward_for_emoji_reaction + relay_for_emoji_reaction + end + rescue Seahorse::Client::NetworkingError + nil + end + + def forward_for_emoji_reaction + return unless @json['signature'].present? + + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url]) + end + + def relay_for_emoji_reaction + return unless @json['signature'].present? && @original_status.public_visibility? + + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [Oj.dump(@json), @original_status.account.id, inbox_url] + end + end + + def shortcode + return @shortcode if defined?(@shortcode) + + @shortcode = begin + if @json['_misskey_reaction'] == '⭐' + nil + else + @json['content']&.delete(':') + end + end + end + + def misskey_favourite? + misskey_shortcode = @json['_misskey_reaction']&.delete(':') + + return misskey_shortcode == shortcode && misskey_shortcode == '⭐' + end + + def emoji_tag + return @emoji_tag if defined?(@emoji_tag) + + @emoji_tag = @json['tag'].is_a?(Array) ? @json['tag']&.first : @json['tag'] + end + + def write_stream(emoji_reaction) + emoji_group = @original_status.emoji_reactions_grouped_by_name + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) } + emoji_group['status_id'] = @original_status.id.to_s + FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id) + end + + def render_emoji_reaction(emoji_group) + @render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) + end +end \ No newline at end of file diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index 9eff1b71c..9b5a72b80 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -100,7 +100,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end end - def undo_like + def undo_like_original status = status_from_uri(target_uri) return if status.nil? || !status.account.local? @@ -113,6 +113,75 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end end + def undo_like + @original_status = status_from_uri(target_uri) + + return if @original_status.nil? + + if shortcode.present? + emoji_tag = @object['tag'].is_a?(Array) ? @object['tag']&.first : @object['tag'] + + emoji = nil + if emoji_tag.present? && emoji_tag['id'].present? + domain = URI.split(emoji_tag['id'])[2] + domain = nil if domain == Rails.configuration.x.local_domain || domain == Rails.configuration.x.web_domain + emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) if emoji_tag.present? && emoji_tag['id'].present? + end + + emoji_reaction = @original_status.emoji_reactions.where(account: @account, name: shortcode, custom_emoji: emoji).first + + if emoji_reaction + emoji_reaction.destroy + write_stream(emoji_reaction) + end + else + undo_like_original + end + end + + def write_stream(emoji_reaction) + emoji_group = @original_status.emoji_reactions_grouped_by_name + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) } + if emoji_group + emoji_group['status_id'] = @original_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' => @original_status.id.to_s } + emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji + end + DeliveryEmojiReactionWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id) if Setting.streaming_emoji_reaction && (@original_status.local? || Setting.streaming_other_servers_emoji_reaction) + end + + def render_emoji_reaction(emoji_group) + @render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json) + end + + def forward_for_undo_emoji_reaction + return unless @json['signature'].present? + + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url]) + end + + def relay_for_undo_emoji_reaction + return unless @json['signature'].present? && @original_status.public_visibility? + + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [Oj.dump(@json), @original_status.account.id, inbox_url] + end + end + + def shortcode + return @shortcode if defined?(@shortcode) + + @shortcode = begin + if @object['_misskey_reaction'] == '⭐' + nil + else + @object['content']&.delete(':') + end + end + end + def undo_block target_account = account_from_uri(target_uri) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 864328631..0cd211d1f 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -45,8 +45,12 @@ class ActivityPub::TagManager account_status_url(target.account, target) when :emoji emoji_url(target) + when :flag target.uri + + when :emoji_reaction + emoji_reaction_url(target) end end diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 0aebb13fc..fae713704 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/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb new file mode 100644 index 000000000..f98acef30 --- /dev/null +++ b/app/lib/potential_friendship_tracker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class PotentialFriendshipTracker + EXPIRE_AFTER = 90.days.seconds + MAX_ITEMS = 80 + + WEIGHTS = { + reply: 1, + emoji_reaction: 2, + 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 diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index 2bb6fed5a..5c5ef3efa 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -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 diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 1c9b44395..e8cc7d45d 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -28,15 +28,16 @@ class CustomEmoji < ApplicationRecord SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' - SCAN_RE = /(?<=[^[:alnum:]:]|\n|^) - :(#{SHORTCODE_RE_FRAGMENT}): - (?=[^[:alnum:]:]|$)/x + SCAN_RE = /:(#{SHORTCODE_RE_FRAGMENT}):/x SHORTCODE_ONLY_RE = /\A#{SHORTCODE_RE_FRAGMENT}\z/ IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze 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 diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb new file mode 100644 index 000000000..44e880d9f --- /dev/null +++ b/app/models/emoji_reaction.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: emoji_reactions +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# name :string default(""), not null +# custom_emoji_id :bigint(8) +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# + +class EmojiReaction < ApplicationRecord + include Paginable + + EMOJI_REACTION_LIMIT = 32767 + EMOJI_REACTION_PER_ACCOUNT_LIMIT = 5 + + update_index('statuses', :status) + + belongs_to :account, inverse_of: :emoji_reactions + belongs_to :status, inverse_of: :emoji_reactions + belongs_to :custom_emoji, optional: true + + has_one :notification, as: :activity, dependent: :destroy + + after_create :refresh_cache + after_destroy :refresh_cache + after_destroy :invalidate_cleanup_info + + private + + def refresh_cache + status&.refresh_emoji_reactions_grouped_by_name! + end + + def invalidate_cleanup_info + return unless status&.account_id == account_id && account.local? + + account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unfav) + end + + def paginate_by_max_id(limit, max_id = nil, since_id = nil) + query = order(arel_table[:id].desc).limit(limit) + query = query.where(arel_table[:id].lt(max_id)) if max_id.present? + query = query.where(arel_table[:id].gt(since_id)) if since_id.present? + query + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 54212d675..52265fc5f 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -25,6 +25,7 @@ class Notification < ApplicationRecord 'Follow' => :follow, 'FollowRequest' => :follow_request, 'Favourite' => :favourite, + 'EmojiReaction' => :emoji_reaction, 'Poll' => :poll, }.freeze @@ -35,6 +36,7 @@ class Notification < ApplicationRecord follow follow_request favourite + emoji_reaction poll update admin.sign_up @@ -46,6 +48,7 @@ class Notification < ApplicationRecord reblog: [status: :reblog], mention: [mention: :status], favourite: [favourite: :status], + emoji_reaction: [emoji_reaction: :status], poll: [poll: :status], update: :status, 'admin.report': [report: :target_account], @@ -61,6 +64,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 +86,8 @@ class Notification < ApplicationRecord status&.reblog when :favourite favourite&.status + when :emoji_reaction + emoji_reaction&.status when :mention mention&.status when :poll @@ -130,6 +137,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 +159,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/models/status.rb b/app/models/status.rb index 1637da292..a8684b617 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -30,6 +30,8 @@ # ordered_media_attachment_ids :bigint(8) is an Array # +require 'ostruct' + class Status < ApplicationRecord include Cacheable include Discard::Model @@ -67,6 +69,7 @@ class Status < ApplicationRecord end has_many :favourites, inverse_of: :status, dependent: :destroy + has_many :emoji_reactions, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :reblogged_by_accounts, through: :reblogs, class_name: 'Account', source: :account @@ -323,6 +326,34 @@ class Status < ApplicationRecord update_status_stat!(key => [public_send(key) - 1, 0].max) end + def emoji_reactions_grouped_by_name(account = nil) + (Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions| + if account.present? + emoji_reactions.each do |emoji_reaction| + emoji_reaction['me'] = emoji_reaction['account_ids'].include?(account.id.to_s) + emoji_reaction['account_ids'] -= account.excluded_from_timeline_account_ids.map(&:to_s) + emoji_reaction['count'] = emoji_reaction['account_ids'].size + end + end + end + end + + def generate_emoji_reactions_grouped_by_name + records = emoji_reactions.group(:name).order(Arel.sql('MIN(created_at) ASC')).select('name, min(custom_emoji_id) as custom_emoji_id, count(*) as count, array_agg(account_id::text order by created_at) as account_ids') + Oj.dump(ActiveModelSerializers::SerializableResource.new(records, each_serializer: REST::EmojiReactionsGroupedByNameSerializer, scope: nil, scope_name: :current_user)) + end + + def refresh_emoji_reactions_grouped_by_name! + generate_emoji_reactions_grouped_by_name.tap do |emoji_reactions| + update_status_stat!(emoji_reactions: emoji_reactions) + end + end + + def generate_emoji_reactions_grouped_by_account + # TODO for serializer + EmojiReaction.where(status_id: id).group_by(&:account) + end + def trendable? if attributes['trendable'].nil? account.trendable? @@ -364,6 +395,10 @@ class Status < ApplicationRecord StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end + def emoji_reactions_map(status_ids, account_id) + EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |e, h| h[e.status_id] = true } + end + def reload_stale_associations!(cached_items) account_ids = [] diff --git a/app/models/status_emoji_reactions_grouped_by_account_object.rb b/app/models/status_emoji_reactions_grouped_by_account_object.rb new file mode 100644 index 000000000..4dd33d805 --- /dev/null +++ b/app/models/status_emoji_reactions_grouped_by_account_object.rb @@ -0,0 +1,14 @@ +class StatusEmojiReactionsGroupedByAccountObject + def initialize(account, emoji_reactions) + @account = account + @emoji_reactions = emoji_reactions + end + + def read_attribute_for_serialization(_) + REST::EmojiReactedBySlimSerializer + end + + def model_name + 'EmojiReaction' + end +end diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb index 47aa14477..55e6a431b 100644 --- a/app/models/status_stat.rb +++ b/app/models/status_stat.rb @@ -11,6 +11,7 @@ # favourites_count :bigint(8) default(0), not null # created_at :datetime not null # updated_at :datetime not null +# emoji_reactions :string # class StatusStat < ApplicationRecord @@ -27,4 +28,14 @@ class StatusStat < ApplicationRecord def favourites_count [attributes['favourites_count'], 0].max end + + def emoji_reactions + attributes['emoji_reactions'] || '' + end + + private + + def reset_parent_cache + Rails.cache.delete("statuses/#{status_id}") + end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 540e26642..cc8d0439c 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/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 5d53040fb..2991c7748 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -4,16 +4,17 @@ class StatusRelationshipsPresenter PINNABLE_VISIBILITIES = %w(public unlisted private).freeze attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, - :bookmarks_map, :filters_map, :attributes_map + :bookmarks_map, :filters_map, :attributes_map, :emoji_reactions_map def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? - @reblogs_map = {} - @favourites_map = {} - @bookmarks_map = {} - @mutes_map = {} - @pins_map = {} - @filters_map = {} + @reblogs_map = {} + @favourites_map = {} + @bookmarks_map = {} + @mutes_map = {} + @pins_map = {} + @filters_map = {} + @emoji_reactions_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact @@ -27,6 +28,7 @@ class StatusRelationshipsPresenter @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) @attributes_map = options[:attributes_map] || {} + @emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {}) end end diff --git a/app/serializers/activitypub/emoji_reaction_serializer.rb b/app/serializers/activitypub/emoji_reaction_serializer.rb new file mode 100644 index 000000000..01ff4e172 --- /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 + 'EmojiReact' + 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 000000000..7bba3c88b --- /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/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index 33da69da5..2d4706864 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,11 +3,11 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer include RoutingHelper - # Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes - attributes :shortcode, :url, :static_url, :visible_in_picker attribute :category, if: :category_loaded? + attribute :width, if: :width? + attribute :height, if: :height? def url full_asset_url(object.image.url) @@ -24,4 +24,20 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer def category_loaded? object.association(:category).loaded? && object.category.present? end + + def width? + object.respond_to?(:image_width) || object.respond_to?(:width) + end + + def height? + object.respond_to?(:image_height) || object.respond_to?(:height) + end + + def width + object.respond_to?(:image_width) ? object.image_width : object.width + end + + def height + object.respond_to?(:image_height) ? object.image_height : object.height + end end diff --git a/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb b/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb new file mode 100644 index 000000000..234218f2e --- /dev/null +++ b/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class REST::EmojiReactedBySlimReactionSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :name + + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + + 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 + + def custom_emoji? + object.custom_emoji.present? + end +end diff --git a/app/serializers/rest/emoji_reacted_by_slim_serializer.rb b/app/serializers/rest/emoji_reacted_by_slim_serializer.rb new file mode 100644 index 000000000..6a0888d11 --- /dev/null +++ b/app/serializers/rest/emoji_reacted_by_slim_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class REST::EmojiReactedBySlimSerializer < ActiveModel::Serializer + + belongs_to :account, serializer: REST::AccountSerializer + has_many :emoji_reactions, serializer: REST::EmojiReactedBySlimReactionSerializer + + def attributes + { account => emoji_reactions } + end + + private + + def account + object.first + end + + def emoji_reactions + object.last + end +end diff --git a/app/serializers/rest/emoji_reaction_account_serializer.rb b/app/serializers/rest/emoji_reaction_account_serializer.rb new file mode 100644 index 000000000..192575407 --- /dev/null +++ b/app/serializers/rest/emoji_reaction_account_serializer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class REST::EmojiReactionAccountSerializer < ActiveModel::Serializer + include RoutingHelper + include FormattingHelper + + attributes :id, :name + + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + + belongs_to :account, serializer: REST::AccountSerializer + + def id + object.id.to_s + 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 + + def custom_emoji? + object.custom_emoji.present? + 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 000000000..fd58a125f --- /dev/null +++ b/app/serializers/rest/emoji_reaction_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class REST::EmojiReactionSerializer < ActiveModel::Serializer + attributes :name + + attribute :count, if: :count? + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + attribute :account_ids, if: :account_ids? + + def count? + object.respond_to?(:count) + end + + def custom_emoji? + object.respond_to?(:url) + end + + def account_ids? + object.respond_to?(:account_ids) + end +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 new file mode 100644 index 000000000..d4230de61 --- /dev/null +++ b/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class REST::EmojiReactionsGroupedByNameSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :name, :count + + attribute :me, if: :current_user? + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + attribute :account_ids, if: :account_ids? + + def current_user? + !current_user.nil? + end + + def 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/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index af6c17d3c..5194857b4 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :domain, :title, :version, :source_url, :description, :usage, :thumbnail, :languages, :configuration, - :registrations, :feature_quote + :registrations, :feature_quote, :fedibird_capabilities has_one :contact, serializer: ContactSerializer has_many :rules, serializer: REST::RuleSerializer @@ -81,6 +81,11 @@ class REST::InstanceSerializer < ActiveModel::Serializer translation: { enabled: TranslationService.configured?, }, + + emoji_reactions: { + max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT, + max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT, + }, } end @@ -97,6 +102,17 @@ class REST::InstanceSerializer < ActiveModel::Serializer true end + # for third party apps + def fedibird_capabilities + capabilities = [ + :emoji_reaction, + ] + + capabilities << :profile_search unless Chewy.enabled? + + capabilities + end + private def registrations_enabled? diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 137fc53dd..f3235b3b1 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -6,16 +6,21 @@ class REST::NotificationSerializer < ActiveModel::Serializer belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer + belongs_to :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer def id object.id.to_s end def status_type? - [:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type) + [:favourite, :emoji_reaction, :reblog, :status, :mention, :poll, :update].include?(object.type) end def report_type? object.type == :'admin.report' end + + def emoji_reaction_type? + object.type == :emoji_reaction + end end diff --git a/app/serializers/rest/notify_emoji_reaction_serializer.rb b/app/serializers/rest/notify_emoji_reaction_serializer.rb new file mode 100644 index 000000000..a78740d56 --- /dev/null +++ b/app/serializers/rest/notify_emoji_reaction_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class REST::NotifyEmojiReactionSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :name + + attribute :count, if: :count? + attribute :url, if: :custom_emoji? + attribute :static_url, if: :custom_emoji? + attribute :domain, if: :custom_emoji? + + def count? + object.respond_to?(:count) + 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/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index ce49cb04f..504f8241b 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -6,7 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility, :language, :uri, :url, :replies_count, :reblogs_count, - :favourites_count, :edited_at + :favourites_count, :emoji_reactions, :edited_at attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? @@ -103,6 +103,10 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def emoji_reactions + object.emoji_reactions_grouped_by_name(current_user&.account) + end + def reblogged if relationships relationships.reblogs_map[object.id] || false diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index fdf939cfc..fc405850a 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -6,7 +6,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer attributes :uri, :title, :short_description, :description, :email, :version, :urls, :stats, :thumbnail, :languages, :registrations, :approval_required, :invites_enabled, - :configuration + :configuration, :fedibird_capabilities has_one :contact_account, serializer: REST::AccountSerializer @@ -83,6 +83,11 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer min_expiration: PollValidator::MIN_EXPIRATION, max_expiration: PollValidator::MAX_EXPIRATION, }, + + emoji_reactions: { + max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT, + max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT, + }, } end @@ -98,6 +103,17 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer UserRole.everyone.can?(:invite_users) end + # for third party apps + def fedibird_capabilities + capabilities = [ + :emoji_reaction, + ] + + capabilities << :profile_search unless Chewy.enabled? + + capabilities + end + private def instance_presenter diff --git a/app/services/concerns/account_limitable.rb b/app/services/concerns/account_limitable.rb new file mode 100644 index 000000000..8a55d857c --- /dev/null +++ b/app/services/concerns/account_limitable.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module AccountLimitable + def scope_status(status) + case status.visibility.to_sym + when :public, :unlisted + #scope_local.merge(scope_list_following_account(status.account)) + scope_local + when :private + scope_account_local_followers(status.account) + when :limited + scope_status_mentioned(status) + else + scope_status_mentioned(status) + end + end + + def scope_local + Account.local.select(:id) + end + + def scope_account_local_followers(account) + account.followers_for_local_distribution.select(:id).reorder(nil) + end + + def scope_status_mentioned(status) + status.active_mentions.where.joins(:account).merge(Account.local).select('account_id AS id').reorder(nil) + end + + # TODO: not work + def scope_list_following_account(account) + account.lists_for_local_distribution.select(:id).reorder(nil) + end + + def scope_tag_following_account(status) + TagFollow.where(tag_id: @status.tags.map(&:id)).select('account_id AS id').reorder(nil) + end +end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index 7c7cb97df..9dda1631e 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -147,6 +147,7 @@ class DeleteAccountService < BaseService purge_polls! purge_generated_notifications! purge_favourites! + purge_emoji_reactions! purge_bookmarks! purge_feeds! purge_other_associations! @@ -193,6 +194,16 @@ class DeleteAccountService < BaseService end end + def purge_emoji_reactions! + @account.emoji_reactions.in_batches do |reactions| + reactions.each do |reaction| + reaction.status.refresh_emoji_reactions_grouped_by_name + end + Chewy.strategy.current.update(StatusesIndex, reactions.pluck(:status_id)) if Chewy.enabled? + reactions.delete_all + end + end + def purge_bookmarks! @account.bookmarks.in_batches do |bookmarks| Chewy.strategy.current.update(StatusesIndex, bookmarks.pluck(:status_id)) if Chewy.enabled? diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb new file mode 100644 index 000000000..d8f51548a --- /dev/null +++ b/app/services/emoji_react_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class EmojiReactService < BaseService + include Authorization + include Payloadable + include Redisable + + # 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) + write_stream(emoji_reaction) + + emoji_reaction + end + + private + + def create_notification(emoji_reaction) + status = emoji_reaction.status + + if status.account.local? + 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 + end + + def notify_to_followers(emoji_reaction) + status = emoji_reaction.status + + return unless status.account.local? + + 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.custom_emoji&.domain) } + emoji_group['status_id'] = emoji_reaction.status_id.to_s + FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), emoji_reaction.status_id, emoji_reaction.account_id) + 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) + 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 new file mode 100644 index 000000000..69b1a9392 --- /dev/null +++ b/app/services/un_emoji_react_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class UnEmojiReactService < BaseService + include Redisable + include Payloadable + + def call(account_id, status_id, emoji_reaction = nil) + @account_id = account_id + @account = Account.find(account_id) + @status = Status.find(status_id) + + p '==================================== DEBUG' + p emoji_reaction + + if emoji_reaction + emoji_reaction.destroy! + create_notification(emoji_reaction) if !@account.local? && @account.activitypub? + notify_to_followers(emoji_reaction) if @account.local? + write_stream(emoji_reaction) + else + bulk(@account, @status) + end + emoji_reaction + end + + private + + def bulk(account, status) + EmojiReaction.where(account: account).where(status: status).each do |emoji_reaction| + call(account.id, status.id, emoji_reaction) + end + end + + def create_notification(emoji_reaction) + ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), @account_id, @account.inbox_url) + end + + def notify_to_followers(emoji_reaction) + ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), @account_id) + end + + def write_stream(emoji_reaction) + emoji_group = @status.emoji_reactions_grouped_by_name + .find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) } + if emoji_group + emoji_group['status_id'] = @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' => @status.id.to_s } + emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji + end + FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @status.id, @account_id) + end + + def build_json(emoji_reaction) + Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer)) + end + + def render_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 diff --git a/app/workers/feed_any_json_worker.rb b/app/workers/feed_any_json_worker.rb new file mode 100644 index 000000000..33aa25477 --- /dev/null +++ b/app/workers/feed_any_json_worker.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class FeedAnyJsonWorker + include Sidekiq::Worker + include Redisable + include Lockable + include AccountLimitable + + def perform(payload_json, status_id, my_account_id = nil) + p '========================================= DEBUG AAA' + redis.publish("timeline:#{my_account_id}", payload_json) if my_account_id.present? + p '========================================= DEBUG AA' + p status_id + p status_id.to_i + + status = Status.find(status_id.to_i) + p '========================================= DEBUG AAAAAAAA' + p status.present? + + if status.present? + p '========================================= DEBUG A' + p scope_status(status) + p '========================================= DEBUG C' + scope_status(status).find_each do |account| + p '========================================= DEBUG D' + p redis.exists?("subscribed:timeline:#{account.id}") + redis.publish("timeline:#{account.id}", payload_json) if redis.exists?("subscribed:timeline:#{account.id}") + end + + if status.visibility.to_sym != :public && status.visibility.to_sym != :unlisted && status.account_id != my_account_id && + redis.exists?("subscribed:timeline:#{status.account_id}") + redis.publish("timeline:#{status.account_id}", payload_json) + end + end + + true + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/un_emoji_react_worker.rb b/app/workers/un_emoji_react_worker.rb new file mode 100644 index 000000000..fce8019fb --- /dev/null +++ b/app/workers/un_emoji_react_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UnEmojiReactWorker + include Sidekiq::Worker + + def perform(account_id, status_id, emoji = nil) + emoji_reaction = nil + + if emoji + shortcode, domain = emoji.split('@') + emoji_reaction = EmojiReaction.where(account_id: account_id).where(status_id: status_id).where(name: shortcode) + .find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain } + end + + UnEmojiReactService.new.call(account_id.to_i, status_id.to_i, emoji_reaction) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d739be07..9ffe65eca 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1459,9 +1459,13 @@ en: sign_up: subject: "%{name} signed up" favourite: - body: 'Your post was favorited by %{name}:' - subject: "%{name} favorited your post" - title: New favorite + body: 'Your post was favourited by %{name}:' + subject: "%{name} favourited your post" + title: New favourite + emoji_reaction: + body: 'Your post was reacted with emoji by %{name}:' + subject: "%{name} reacted your post with emoji" + title: New emoji reaction follow: body: "%{name} is now following you!" subject: "%{name} is now following you" diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 02bbe5171..4e95073fc 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1436,6 +1436,10 @@ ja: body: "%{name}さんにお気に入り登録された、あなたのトゥートがあります:" subject: "%{name}さんにお気に入りに登録されました" title: 新たなお気に入り登録 + emoji_reaction: + body: "%{name}さんに絵文字リアクションされた、あなたの投稿があります:" + subject: "%{name}さんに絵文字リアクションされました" + title: 新たな絵文字リアクション follow: body: "%{name}さんにフォローされています!" subject: "%{name}さんにフォローされています" diff --git a/config/routes.rb b/config/routes.rb index bb088821f..26a8688f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ Rails.application.routes.draw do /lists/(*any) /notifications /favourites + /emoji_reactions /bookmarks /pinned /start/(*any) diff --git a/config/routes/api.rb b/config/routes/api.rb index 853a44e0e..a70ed0edb 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -10,6 +10,8 @@ namespace :api, format: false do scope module: :statuses do resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index + resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index + resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' @@ -29,6 +31,9 @@ namespace :api, format: false do resource :source, only: :show post :translate, to: 'translations#create' + + resources :emoji_reactions, only: [:create, :update, :destroy], constraints: { id: /[^\/]+/ } + post :emoji_unreaction, to: 'emoji_reactions#destroy' end member do diff --git a/db/migrate/20230222232121_create_emoji_reactions.rb b/db/migrate/20230222232121_create_emoji_reactions.rb new file mode 100644 index 000000000..7edd6f289 --- /dev/null +++ b/db/migrate/20230222232121_create_emoji_reactions.rb @@ -0,0 +1,13 @@ +class CreateEmojiReactions < ActiveRecord::Migration[6.1] + def change + create_table :emoji_reactions do |t| + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.string :name, null: false, default: '' + t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade } + t.string :uri + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + end +end diff --git a/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb b/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb new file mode 100644 index 000000000..7f2ec68cc --- /dev/null +++ b/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb @@ -0,0 +1,5 @@ +class AddEmojiReactionsToStatusStats < ActiveRecord::Migration[6.1] + def change + add_column :status_stats, :emoji_reactions, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index ff6a675eb..cfdb39606 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -439,6 +439,19 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true end + create_table "emoji_reactions", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.string "name", default: "", null: false + t.bigint "custom_emoji_id" + t.string "uri" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_emoji_reactions_on_account_id" + t.index ["custom_emoji_id"], name: "index_emoji_reactions_on_custom_emoji_id" + t.index ["status_id"], name: "index_emoji_reactions_on_status_id" + end + create_table "encrypted_messages", id: :bigint, default: -> { "timestamp_id('encrypted_messages'::text)" }, force: :cascade do |t| t.bigint "device_id" t.bigint "from_account_id" @@ -967,6 +980,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do t.bigint "favourites_count", default: 0, null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.string "emoji_reactions" t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true end @@ -1226,6 +1240,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do add_foreign_key "devices", "accounts", on_delete: :cascade add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade + add_foreign_key "emoji_reactions", "accounts", on_delete: :cascade + add_foreign_key "emoji_reactions", "custom_emojis", on_delete: :cascade + add_foreign_key "emoji_reactions", "statuses", on_delete: :cascade add_foreign_key "encrypted_messages", "accounts", column: "from_account_id", on_delete: :cascade add_foreign_key "encrypted_messages", "devices", on_delete: :cascade add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade diff --git a/lib/mastodon/cache_cli.rb b/lib/mastodon/cache_cli.rb new file mode 100644 index 000000000..71da96179 --- /dev/null +++ b/lib/mastodon/cache_cli.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative '../../config/boot' +require_relative '../../config/environment' +require_relative 'cli_helper' + +module Mastodon + class CacheCLI < Thor + include CLIHelper + + def self.exit_on_failure? + true + end + + desc 'clear', 'Clear out the cache storage' + def clear + Rails.cache.clear + say('OK', :green) + end + + option :concurrency, type: :numeric, default: 5, aliases: [:c] + option :verbose, type: :boolean, aliases: [:v] + desc 'recount TYPE', 'Update hard-cached counters' + long_desc <<~LONG_DESC + Update hard-cached counters of TYPE by counting referenced + records from scratch. TYPE can be "accounts" or "statuses". + + It may take a very long time to finish, depending on the + size of the database. + LONG_DESC + def recount(type) + case type + when 'accounts' + processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account| + account_stat = account.account_stat + account_stat.following_count = account.active_relationships.count + account_stat.followers_count = account.passive_relationships.count + account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count + + account_stat.save if account_stat.changed? + end + when 'statuses' + processed, = parallelize_with_progress(Status.includes(:status_stat)) do |status| + status_stat = status.status_stat + status_stat.replies_count = status.replies.where.not(visibility: :direct).count + status_stat.reblogs_count = status.reblogs.count + status_stat.favourites_count = status.favourites.count + status_stat.emoji_reactions = status.generate_emoji_reactions_grouped_by_name + + status_stat.save if status_stat.changed? + end + else + say("Unknown type: #{type}", :red) + exit(1) + end + + say + say("OK, recounted #{processed} records", :green) + end + end +end