From 7facf831e12909e6827641af098250f2b04eea76 Mon Sep 17 00:00:00 2001 From: fef Date: Thu, 24 Nov 2022 11:50:32 +0000 Subject: [PATCH 01/48] add backend support for status emoji reactions turns out we can just reuse the code for announcement reactions. --- .../api/v1/statuses/reactions_controller.rb | 29 +++++++++++++++++++ app/models/status.rb | 16 ++++++++++ app/models/status_reaction.rb | 29 +++++++++++++++++++ app/serializers/rest/status_serializer.rb | 5 ++++ app/validators/status_reaction_validator.rb | 28 ++++++++++++++++++ config/routes.rb | 1 + .../20221124114030_create_status_reactions.rb | 14 +++++++++ .../fabricators/status_reaction_fabricator.rb | 6 ++++ spec/models/status_reaction_spec.rb | 5 ++++ 9 files changed, 133 insertions(+) create mode 100644 app/controllers/api/v1/statuses/reactions_controller.rb create mode 100644 app/models/status_reaction.rb create mode 100644 app/validators/status_reaction_validator.rb create mode 100644 db/migrate/20221124114030_create_status_reactions.rb create mode 100644 spec/fabricators/status_reaction_fabricator.rb create mode 100644 spec/models/status_reaction_spec.rb diff --git a/app/controllers/api/v1/statuses/reactions_controller.rb b/app/controllers/api/v1/statuses/reactions_controller.rb new file mode 100644 index 0000000000..9a1bf57079 --- /dev/null +++ b/app/controllers/api/v1/statuses/reactions_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReactionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write, :'write:favourites' } + before_action :require_user! + + before_action :set_status + before_action :set_reaction, except: :update + + def update + @status.status_reactions.create!(account: current_account, name: params[:id]) + render_empty + end + + def destroy + @reaction.destroy! + render_empty + end + + private + + def set_reaction + @reaction = @status.status_reactions.where(account: current_account).find_by!(name: params[:id]) + end + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 6cfe19d238..64f95f3d05 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -71,6 +71,7 @@ class Status < ApplicationRecord has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :status_reactions, dependent: :destroy has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -263,6 +264,21 @@ class Status < ApplicationRecord @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) end + def reactions(account = nil) + records = begin + scope = status_reactions.group(:status_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC')) + + if account.nil? + scope.select('name, custom_emoji_id, count(*) as count, false as me') + else + scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from status_reactions r where r.account_id = #{account.id} and r.status_id = status_reactions.status_id and r.name = status_reactions.name) as me") + end + end + + ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji) + records + end + def ordered_media_attachments if ordered_media_attachment_ids.nil? media_attachments diff --git a/app/models/status_reaction.rb b/app/models/status_reaction.rb new file mode 100644 index 0000000000..32cb9edd4a --- /dev/null +++ b/app/models/status_reaction.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_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) +# created_at :datetime not null +# updated_at :datetime not null +# +class StatusReaction < ApplicationRecord + belongs_to :account + belongs_to :status, inverse_of: :status_reactions + belongs_to :custom_emoji, optional: true + + validates :name, presence: true + validates_with StatusReactionValidator + + before_validation :set_custom_emoji + + private + + def set_custom_emoji + self.custom_emoji = CustomEmoji.local.find_by(disabled: false, shortcode: name) if name.present? + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 659c45b835..43c1e86aff 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_many :ordered_mentions, key: :mentions has_many :tags has_many :emojis, serializer: REST::CustomEmojiSerializer + has_many :reactions, serializer: REST::ReactionSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer @@ -146,6 +147,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.active_mentions.to_a.sort_by(&:id) end + def reactions + object.reactions(current_user&.account) + end + class ApplicationSerializer < ActiveModel::Serializer attributes :name, :website diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb new file mode 100644 index 0000000000..7c1c6983bb --- /dev/null +++ b/app/validators/status_reaction_validator.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class StatusReactionValidator < ActiveModel::Validator + SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze + + LIMIT = 8 + + def validate(reaction) + return if reaction.name.blank? + + reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name) + reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if new_reaction?(reaction) && limit_reached?(reaction) + end + + private + + def unicode_emoji?(name) + SUPPORTED_EMOJIS.include?(name) + end + + def new_reaction?(reaction) + !reaction.status.status_reactions.where(name: reaction.name).exists? + end + + def limit_reached?(reaction) + reaction.status.status_reactions.where.not(name: reaction.name).count('distinct name') >= LIMIT + end +end diff --git a/config/routes.rb b/config/routes.rb index 8639f0ef53..da4ddfa200 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -453,6 +453,7 @@ Rails.application.routes.draw do resource :history, only: :show resource :source, only: :show + resources :reactions, only: [:update, :destroy] post :translate, to: 'translations#create' end diff --git a/db/migrate/20221124114030_create_status_reactions.rb b/db/migrate/20221124114030_create_status_reactions.rb new file mode 100644 index 0000000000..bbf1f3376a --- /dev/null +++ b/db/migrate/20221124114030_create_status_reactions.rb @@ -0,0 +1,14 @@ +class CreateStatusReactions < ActiveRecord::Migration[6.1] + def change + create_table :status_reactions do |t| + t.references :account, null: false, foreign_key: true + t.references :status, null: false, foreign_key: true + t.string :name, null: false, default: '' + t.references :custom_emoji, null: true, foreign_key: true + + t.timestamps + end + + add_index :status_reactions, [:account_id, :status_id, :name], unique: true, name: :index_status_reactions_on_account_id_and_status_id + end +end diff --git a/spec/fabricators/status_reaction_fabricator.rb b/spec/fabricators/status_reaction_fabricator.rb new file mode 100644 index 0000000000..3d4b93efe0 --- /dev/null +++ b/spec/fabricators/status_reaction_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:status_reaction) do + account nil + status nil + name "MyString" + custom_emoji nil +end \ No newline at end of file diff --git a/spec/models/status_reaction_spec.rb b/spec/models/status_reaction_spec.rb new file mode 100644 index 0000000000..18860318cc --- /dev/null +++ b/spec/models/status_reaction_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe StatusReaction, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From dcea57a80833bf5ae6308879aeddf52f8bb9276c Mon Sep 17 00:00:00 2001 From: fef Date: Thu, 24 Nov 2022 17:30:52 +0000 Subject: [PATCH 02/48] add frontend for emoji reactions this is still pretty bare bones but hey, it works. --- .../flavours/glitch/actions/interactions.js | 85 +++++++++ .../flavours/glitch/components/status.js | 12 ++ .../glitch/components/status_reactions_bar.js | 177 ++++++++++++++++++ .../glitch/containers/status_container.js | 16 +- .../flavours/glitch/reducers/statuses.js | 44 +++++ 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 app/javascript/flavours/glitch/components/status_reactions_bar.js diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 225ee7eb2a..ab275621c2 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -41,6 +41,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const STATUS_REACTION_UPDATE = 'STATUS_REACTION_UPDATE'; + +export const STATUS_REACTION_ADD_REQUEST = 'STATUS_REACTION_ADD_REQUEST'; +export const STATUS_REACTION_ADD_SUCCESS = 'STATUS_REACTION_ADD_SUCCESS'; +export const STATUS_REACTION_ADD_FAIL = 'STATUS_REACTION_ADD_FAIL'; + +export const STATUS_REACTION_REMOVE_REQUEST = 'STATUS_REACTION_REMOVE_REQUEST'; +export const STATUS_REACTION_REMOVE_SUCCESS = 'STATUS_REACTION_REMOVE_SUCCESS'; +export const STATUS_REACTION_REMOVE_FAIL = 'STATUS_REACTION_REMOVE_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -392,3 +402,78 @@ export function unpinFail(status, error) { error, }; }; + +export const statusAddReaction = (statusId, name) => (dispatch, getState) => { + const status = getState().get('statuses').get(statusId); + let alreadyAdded = false; + if (status) { + const reaction = status.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + if (!alreadyAdded) { + dispatch(statusAddReactionRequest(statusId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => { + dispatch(statusAddReactionSuccess(statusId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(statusAddReactionFail(statusId, name, err)); + } + }); +}; + +export const statusAddReactionRequest = (statusId, name) => ({ + type: STATUS_REACTION_ADD_REQUEST, + id: statusId, + name, + skipLoading: true, +}); + +export const statusAddReactionSuccess = (statusId, name) => ({ + type: STATUS_REACTION_ADD_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const statusAddReactionFail = (statusId, name, error) => ({ + type: STATUS_REACTION_ADD_FAIL, + id: statusId, + name, + error, + skipLoading: true, +}); + +export const statusRemoveReaction = (statusId, name) => (dispatch, getState) => { + dispatch(statusRemoveReactionRequest(statusId, name)); + + api(getState).delete(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => { + dispatch(statusRemoveReactionSuccess(statusId, name)); + }).catch(err => { + dispatch(statusRemoveReactionFail(statusId, name, err)); + }); +}; + +export const statusRemoveReactionRequest = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_REQUEST, + id: statusId, + name, + skipLoading: true, +}); + +export const statusRemoveReactionSuccess = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const statusRemoveReactionFail = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_FAIL, + id: statusId, + name, + skipLoading: true, +}); diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 4041b48194..31b234ef30 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -6,6 +6,7 @@ import StatusHeader from './status_header'; import StatusIcons from './status_icons'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; +import StatusReactionsBar from './status_reactions_bar'; import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; @@ -75,6 +76,8 @@ class Status extends ImmutablePureComponent { onDelete: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, + onReactionAdd: PropTypes.func, + onReactionRemove: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, @@ -102,6 +105,7 @@ class Status extends ImmutablePureComponent { scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, settings: ImmutablePropTypes.map.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, pictureInPicture: PropTypes.shape({ inUse: PropTypes.bool, available: PropTypes.bool, @@ -800,6 +804,14 @@ class Status extends ImmutablePureComponent { rewriteMentions={settings.get('rewrite_mentions')} /> + + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { + const { addReaction, statusId } = this.props; + addReaction(statusId, data.native.replace(/:/g, '')); + } + + willEnter() { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave() { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render() { + const { reactions } = this.props; + const visibleReactions = reactions.filter(x => x.get('count') > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render() { + const { reaction } = this.props; + + let shortCode = reaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + hovered: PropTypes.bool.isRequired, + }; + + render() { + const { emoji, emojiMap, hovered } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) + ? emojiMap.getIn([emoji, 'url']) + : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } else { + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 947573fc7d..40a5744379 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; import Status from 'flavours/glitch/components/status'; -import { List as ImmutableList } from 'immutable'; import { makeGetStatus } from 'flavours/glitch/selectors'; import { replyCompose, @@ -16,6 +15,8 @@ import { unbookmark, pin, unpin, + statusAddReaction, + statusRemoveReaction, } from 'flavours/glitch/actions/interactions'; import { muteStatus, @@ -44,6 +45,10 @@ import { showAlertForError } from '../actions/alerts'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Spoilers from '../components/spoilers'; import Icon from 'flavours/glitch/components/icon'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; + +const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -83,6 +88,7 @@ const makeMapStateToProps = () => { account: account || props.account, settings: state.get('local_settings'), prepend: prepend || props.prepend, + emojiMap: customEmojiMap(state), pictureInPicture: { inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, @@ -166,6 +172,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name) { + dispatch(statusAddReaction(statusId, name)); + }, + + onReactionRemove (statusId, name) { + dispatch(statusRemoveReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal('EMBED', { url: status.get('url'), diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index f0c4c804b7..605238bc29 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -6,6 +6,11 @@ import { UNFAVOURITE_SUCCESS, BOOKMARK_REQUEST, BOOKMARK_FAIL, + STATUS_REACTION_UPDATE, + STATUS_REACTION_ADD_FAIL, + STATUS_REACTION_REMOVE_FAIL, + STATUS_REACTION_ADD_REQUEST, + STATUS_REACTION_REMOVE_REQUEST, } from 'flavours/glitch/actions/interactions'; import { STATUS_MUTE_SUCCESS, @@ -37,6 +42,37 @@ const deleteStatus = (state, id, references) => { return state.delete(id); }; +const updateReaction = (state, id, name, updater) => state.update( + id, + status => status.update( + 'reactions', + reactions => { + const index = reactions.findIndex(reaction => reaction.get('name') === name); + if (index > -1) { + return reactions.update(index, reaction => updater(reaction)); + } else { + return reactions.push(updater(fromJS({ name, count: 0 }))); + } + }, + ), +); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count)); + +const addReaction = (state, id, name) => updateReaction( + state, + id, + name, + x => x.set('me', true).update('count', n => n + 1), +); + +const removeReaction = (state, id, name) => updateReaction( + state, + id, + name, + x => x.set('me', false).update('count', n => n - 1), +); + const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { @@ -63,6 +99,14 @@ export default function statuses(state = initialState, action) { return state.setIn([action.status.get('id'), 'reblogged'], true); case REBLOG_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false); + case STATUS_REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case STATUS_REACTION_ADD_REQUEST: + case STATUS_REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name); + case STATUS_REACTION_REMOVE_REQUEST: + case STATUS_REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); case STATUS_MUTE_SUCCESS: return state.setIn([action.id, 'muted'], true); case STATUS_UNMUTE_SUCCESS: From ff379c7fd06bbe18c93148d380a0281c1c08bb09 Mon Sep 17 00:00:00 2001 From: fef Date: Fri, 25 Nov 2022 23:02:40 +0000 Subject: [PATCH 03/48] show reactions in detailed status view --- .../glitch/containers/status_container.js | 7 ++--- .../status/components/detailed_status.js | 12 +++++++ .../flavours/glitch/features/status/index.js | 31 +++++++++++++++++++ .../flavours/glitch/utils/emoji_map.js | 11 +++++++ 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 app/javascript/flavours/glitch/utils/emoji_map.js diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 40a5744379..381276d9dc 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -45,10 +45,7 @@ import { showAlertForError } from '../actions/alerts'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Spoilers from '../components/spoilers'; import Icon from 'flavours/glitch/components/icon'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; - -const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); +import buildCustomEmojiMap from '../utils/emoji_map'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -88,7 +85,7 @@ const makeMapStateToProps = () => { account: account || props.account, settings: state.get('local_settings'), prepend: prepend || props.prepend, - emojiMap: customEmojiMap(state), + emojiMap: buildCustomEmojiMap(state), pictureInPicture: { inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 7d2c2aace3..b2f38ff1ff 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -20,6 +20,7 @@ import Icon from 'flavours/glitch/components/icon'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; +import StatusReactionsBar from '../../../components/status_reactions_bar'; export default @injectIntl class DetailedStatus extends ImmutablePureComponent { @@ -43,6 +44,9 @@ class DetailedStatus extends ImmutablePureComponent { showMedia: PropTypes.bool, usingPiP: PropTypes.bool, onToggleMediaVisibility: PropTypes.func, + onReactionAdd: PropTypes.func.isRequired, + onReactionRemove: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, intl: PropTypes.object.isRequired, }; @@ -319,6 +323,14 @@ class DetailedStatus extends ImmutablePureComponent { disabled /> + +
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index c716e4f0f5..e94a47f0ee 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -20,6 +20,8 @@ import { unreblog, pin, unpin, + statusAddReaction, + statusRemoveReaction, } from 'flavours/glitch/actions/interactions'; import { replyCompose, @@ -56,6 +58,7 @@ import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status'; import Icon from 'flavours/glitch/components/icon'; import { Helmet } from 'react-helmet'; +import buildCustomEmojiMap from '../../utils/emoji_map'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -146,6 +149,7 @@ const makeMapStateToProps = () => { askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, + emojiMap: buildCustomEmojiMap(state), }; }; @@ -291,6 +295,30 @@ class Status extends ImmutablePureComponent { } } + handleReactionAdd = (statusId, name) => { + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(statusAddReaction(statusId, name)); + } else { + dispatch(openModal('INTERACTION', { + type: 'reaction_add', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); + } + } + + handleReactionRemove = (statusId, name) => { + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(statusRemoveReaction(statusId, name)); + } + } + handlePin = (status) => { if (status.get('pinned')) { this.props.dispatch(unpin(status)); @@ -676,6 +704,8 @@ class Status extends ImmutablePureComponent { settings={settings} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} + onReactionAdd={this.handleReactionAdd} + onReactionRemove={this.handleReactionRemove} expanded={isExpanded} onToggleHidden={this.handleToggleHidden} onTranslate={this.handleTranslate} @@ -683,6 +713,7 @@ class Status extends ImmutablePureComponent { showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} usingPiP={usingPiP} + emojiMap={this.props.emojiMap} /> state.get('custom_emojis')], + items => items.reduce( + (map, emoji) => map.set(emoji.get('shortcode'), emoji), + ImmutableMap(), + ), +); +export default buildCustomEmojiMap; From cc71e734c48274b2b341d850d8856b3b316812ae Mon Sep 17 00:00:00 2001 From: fef Date: Mon, 28 Nov 2022 22:23:13 +0000 Subject: [PATCH 04/48] federate emoji reactions this is kind of experimental, but it should work in theory. at least i tested it with a remove akkoma instance and it didn't crash. --- .../api/v1/statuses/reactions_controller.rb | 4 +-- app/lib/activitypub/activity.rb | 2 ++ app/lib/activitypub/activity/emoji_react.rb | 14 ++++++++ app/models/concerns/account_interactions.rb | 4 +++ app/models/status.rb | 2 +- .../activitypub/emoji_reaction_serializer.rb | 36 +++++++++++++++++++ .../undo_emoji_reaction_serializer.rb | 19 ++++++++++ .../undo_emoji_reaction_serializer.rb | 0 app/services/status_reaction_service.rb | 27 ++++++++++++++ app/services/status_unreaction_service.rb | 21 +++++++++++ 10 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 app/lib/activitypub/activity/emoji_react.rb create mode 100644 app/serializers/activitypub/emoji_reaction_serializer.rb create mode 100644 app/serializers/activitypub/undo_emoji_reaction_serializer.rb create mode 100644 app/serializers/undo_emoji_reaction_serializer.rb create mode 100644 app/services/status_reaction_service.rb create mode 100644 app/services/status_unreaction_service.rb diff --git a/app/controllers/api/v1/statuses/reactions_controller.rb b/app/controllers/api/v1/statuses/reactions_controller.rb index 9a1bf57079..f7dc2f99ce 100644 --- a/app/controllers/api/v1/statuses/reactions_controller.rb +++ b/app/controllers/api/v1/statuses/reactions_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Statuses::ReactionsController < Api::BaseController before_action :set_reaction, except: :update def update - @status.status_reactions.create!(account: current_account, name: params[:id]) + StatusReactionService.new.call(current_account, @status, params[:id]) render_empty end def destroy - @reaction.destroy! + StatusUnreactionService.new.call(current_account, @status) render_empty end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index f4c67cccd7..a6b91f62da 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -39,6 +39,8 @@ class ActivityPub::Activity ActivityPub::Activity::Follow when 'Like' ActivityPub::Activity::Like + when 'EmojiReact' + ActivityPub::Activity::EmojiReact when 'Block' ActivityPub::Activity::Block when 'Update' diff --git a/app/lib/activitypub/activity/emoji_react.rb b/app/lib/activitypub/activity/emoji_react.rb new file mode 100644 index 0000000000..82c098f56e --- /dev/null +++ b/app/lib/activitypub/activity/emoji_react.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::EmojiReact < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + name = @json['content'] + return if original_status.nil? || + !original_status.account.local? || + delete_arrived_first?(@json['id']) || + @account.reacted?(original_status, name) + + original_status.status_reactions.create!(account: @account, name: name) + end +end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index de8bf338f2..6143bc8263 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -239,6 +239,10 @@ module AccountInteractions status.proper.favourites.where(account: self).exists? end + def reacted?(status, name, custom_emoji = nil) + status.proper.status_reactions.where(account: self, name: name, custom_emoji: custom_emoji).exists? + end + def bookmarked?(status) status.proper.bookmarks.where(account: self).exists? end diff --git a/app/models/status.rb b/app/models/status.rb index 64f95f3d05..5a48db2932 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -71,7 +71,7 @@ class Status < ApplicationRecord has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify - has_many :status_reactions, dependent: :destroy + has_many :status_reactions, inverse_of: :status, dependent: :destroy has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards diff --git a/app/serializers/activitypub/emoji_reaction_serializer.rb b/app/serializers/activitypub/emoji_reaction_serializer.rb new file mode 100644 index 0000000000..b4111150a4 --- /dev/null +++ b/app/serializers/activitypub/emoji_reaction_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer + attributes :id, :type, :actor, :content + attribute :virtual_object, key: :object + + has_one :custom_emoji, key: :tag, serializer: ActivityPub::EmojiSerializer, unless: -> { object.custom_emoji.nil? } + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', 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 + if object.custom_emoji.nil? + object.name + else + ":#{object.name}:" + end + end + + def reaction + content + end +end diff --git a/app/serializers/activitypub/undo_emoji_reaction_serializer.rb b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb new file mode 100644 index 0000000000..49f0c1c8fd --- /dev/null +++ b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer + attributes :id, :type, :actor + + 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 +end diff --git a/app/serializers/undo_emoji_reaction_serializer.rb b/app/serializers/undo_emoji_reaction_serializer.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/services/status_reaction_service.rb b/app/services/status_reaction_service.rb new file mode 100644 index 0000000000..17acfe7488 --- /dev/null +++ b/app/services/status_reaction_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class StatusReactionService < BaseService + include Authorization + include Payloadable + + def call(account, status, emoji) + reaction = StatusReaction.find_by(account: account, status: status) + return reaction unless reaction.nil? + + name, domain = emoji.split("@") + + custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain) + reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji) + + json = Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer)) + if status.account.local? + ActivityPub::RawDistributionWorker.perform_async(json, status.account.id) + else + ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url) + end + + ActivityTracker.increment('activity:interactions') + + reaction + end +end diff --git a/app/services/status_unreaction_service.rb b/app/services/status_unreaction_service.rb new file mode 100644 index 0000000000..13c3c428db --- /dev/null +++ b/app/services/status_unreaction_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class StatusUnreactionService < BaseService + include Payloadable + + def call(account, status) + reaction = StatusReaction.find_by(account: account, status: status) + return if reaction.nil? + + reaction.destroy! + + json = Oj.dump(serialize_payload(reaction, ActivityPub::UndoEmojiReactionSerializer)) + if status.account.local? + ActivityPub::RawDistributionWorker.perform_async(json, status.account.id) + else + ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url) + end + + reaction + end +end From 83f0b5f2880f726fb0002ba249fafbed8f22412b Mon Sep 17 00:00:00 2001 From: fef Date: Mon, 28 Nov 2022 22:25:12 +0000 Subject: [PATCH 05/48] remove accidentally created file --- app/serializers/undo_emoji_reaction_serializer.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 app/serializers/undo_emoji_reaction_serializer.rb diff --git a/app/serializers/undo_emoji_reaction_serializer.rb b/app/serializers/undo_emoji_reaction_serializer.rb deleted file mode 100644 index e69de29bb2..0000000000 From 200dd90a9fe716d5a1da322f21eb89e64eb44180 Mon Sep 17 00:00:00 2001 From: fef Date: Mon, 28 Nov 2022 23:16:56 +0000 Subject: [PATCH 06/48] make status reaction count limit configurable --- .env.production.sample | 3 +++ app/validators/status_reaction_validator.rb | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.production.sample b/.env.production.sample index 7bcce0f7e5..a2f5bdd123 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -269,6 +269,9 @@ MAX_POLL_OPTIONS=5 # Maximum allowed poll option characters MAX_POLL_OPTION_CHARS=100 +# Maximum number of emoji reactions per toot and user (minimum 1) +MAX_STATUS_REACTIONS=8 + # Maximum image and video/audio upload sizes # Units are in bytes # 1048576 bytes equals 1 megabyte diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb index 7c1c6983bb..113e9342ba 100644 --- a/app/validators/status_reaction_validator.rb +++ b/app/validators/status_reaction_validator.rb @@ -3,7 +3,7 @@ class StatusReactionValidator < ActiveModel::Validator SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze - LIMIT = 8 + LIMIT = [1, (ENV['MAX_STATUS_REACTIONS'] || 1).to_i].max def validate(reaction) return if reaction.name.blank? From 738433e46a228c83a0748b44ba58a5309a6ff247 Mon Sep 17 00:00:00 2001 From: fef Date: Tue, 29 Nov 2022 00:39:40 +0000 Subject: [PATCH 07/48] make frontend fetch reaction limit the maximum number of reactions was previously hardcoded to 8. this commit also fixes an incorrect query in StatusReactionValidator where it didn't count per-user reactions but the total amount of different ones. --- .env.production.sample | 2 +- .../flavours/glitch/components/status_reactions_bar.js | 5 +++-- app/javascript/flavours/glitch/initial_state.js | 3 +++ app/serializers/initial_state_serializer.rb | 6 +++++- app/services/status_reaction_service.rb | 7 +++---- app/validators/status_reaction_validator.rb | 6 +++--- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.env.production.sample b/.env.production.sample index a2f5bdd123..326c2cc409 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -270,7 +270,7 @@ MAX_POLL_OPTIONS=5 MAX_POLL_OPTION_CHARS=100 # Maximum number of emoji reactions per toot and user (minimum 1) -MAX_STATUS_REACTIONS=8 +MAX_REACTIONS=8 # Maximum image and video/audio upload sizes # Units are in bytes diff --git a/app/javascript/flavours/glitch/components/status_reactions_bar.js b/app/javascript/flavours/glitch/components/status_reactions_bar.js index db1905be4f..ac57341bcc 100644 --- a/app/javascript/flavours/glitch/components/status_reactions_bar.js +++ b/app/javascript/flavours/glitch/components/status_reactions_bar.js @@ -11,13 +11,14 @@ import React from 'react'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; import AnimatedNumber from './animated_number'; import { assetHost } from '../utils/config'; -import { autoPlayGif } from '../initial_state'; +import { autoPlayGif, maxReactions } from '../initial_state'; export default class StatusReactionsBar extends ImmutablePureComponent { static propTypes = { statusId: PropTypes.string.isRequired, reactions: ImmutablePropTypes.list.isRequired, + reactionLimit: PropTypes.number.isRequired, addReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired, emojiMap: ImmutablePropTypes.map.isRequired, @@ -62,7 +63,7 @@ export default class StatusReactionsBar extends ImmutablePureComponent { /> ))} - {visibleReactions.size < 8 && } />} + {visibleReactions.size < maxReactions && } />}
)} diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index bbf25c8a85..4391b0c75d 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -148,4 +148,7 @@ export const pollLimits = (initialState && initialState.poll_limits); export const defaultContentType = getMeta('default_content_type'); export const useSystemEmojiFont = getMeta('system_emoji_font'); +// nyastodon-specific settings +export const maxReactions = (initialState && initialState.max_reactions) || 8; + export default initialState; diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index d753fa51a9..76b2ff2a24 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -6,7 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, :media_attachments, :settings, :max_toot_chars, :poll_limits, - :languages + :languages, :max_reactions has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :role, serializer: REST::RoleSerializer @@ -15,6 +15,10 @@ class InitialStateSerializer < ActiveModel::Serializer StatusLengthValidator::MAX_CHARS end + def max_reactions + StatusReactionValidator::LIMIT + end + def poll_limits { max_options: PollValidator::MAX_OPTIONS, diff --git a/app/services/status_reaction_service.rb b/app/services/status_reaction_service.rb index 17acfe7488..e823f6bd88 100644 --- a/app/services/status_reaction_service.rb +++ b/app/services/status_reaction_service.rb @@ -5,12 +5,11 @@ class StatusReactionService < BaseService include Payloadable def call(account, status, emoji) - reaction = StatusReaction.find_by(account: account, status: status) + name, domain = emoji.split('@') + custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain) + reaction = StatusReaction.find_by(account: account, status: status, name: name, custom_emoji: custom_emoji) return reaction unless reaction.nil? - name, domain = emoji.split("@") - - custom_emoji = CustomEmoji.find_by(shortcode: name, domain: domain) reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji) json = Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer)) diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb index 113e9342ba..fa6fb2e765 100644 --- a/app/validators/status_reaction_validator.rb +++ b/app/validators/status_reaction_validator.rb @@ -3,13 +3,13 @@ class StatusReactionValidator < ActiveModel::Validator SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze - LIMIT = [1, (ENV['MAX_STATUS_REACTIONS'] || 1).to_i].max + LIMIT = [1, (ENV['MAX_REACTIONS'] || 8).to_i].max def validate(reaction) return if reaction.name.blank? reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name) - reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if new_reaction?(reaction) && limit_reached?(reaction) + reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if limit_reached?(reaction) end private @@ -23,6 +23,6 @@ class StatusReactionValidator < ActiveModel::Validator end def limit_reached?(reaction) - reaction.status.status_reactions.where.not(name: reaction.name).count('distinct name') >= LIMIT + reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT end end From 19491d2032322a9e4435179d22065c5d99ea0a2f Mon Sep 17 00:00:00 2001 From: fef Date: Tue, 29 Nov 2022 04:31:22 +0100 Subject: [PATCH 08/48] cherry-pick emoji reaction changes --- .../flavours/glitch/actions/notifications.js | 1 + .../flavours/glitch/components/status.js | 1 + .../glitch/components/status_prepend.js | 11 ++++++ .../glitch/components/status_reactions_bar.js | 1 - .../components/column_settings.js | 11 ++++++ .../notifications/components/filter_bar.js | 8 ++++ .../notifications/components/notification.js | 22 +++++++++++ app/javascript/flavours/glitch/locales/de.js | 5 ++- app/javascript/flavours/glitch/locales/en.js | 4 ++ app/javascript/flavours/glitch/locales/fr.js | 5 ++- .../flavours/glitch/reducers/settings.js | 3 ++ app/models/notification.rb | 38 ++++++++++++------- .../rest/notification_serializer.rb | 2 +- app/services/status_reaction_service.rb | 1 + config/locales/de.yml | 4 ++ config/locales/en.yml | 4 ++ config/locales/en_GB.yml | 4 ++ config/locales/fr.yml | 4 ++ 18 files changed, 111 insertions(+), 18 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 158a5b7e43..fa556269c8 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 31b234ef30..05fecdb968 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -726,6 +726,7 @@ class Status extends ImmutablePureComponent { if (this.props.prepend && account) { const notifKind = { favourite: 'favourited', + reaction: 'reacted', reblog: 'boosted', reblogged_by: 'boosted', status: 'posted', diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index f825330621..6c96f2ee2f 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -56,6 +56,14 @@ export default class StatusPrepend extends React.PureComponent { values={{ name : link }} /> ); + case 'reaction': + return ( + + ); case 'reblog': return ( +
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js index c1de0f90ea..6027a55d85 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -6,6 +6,7 @@ import Icon from 'flavours/glitch/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent { > +
); - } else if (placeholder) { + } else if (placeholder || number) { return (
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 40c86afdf0..b37b003182 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -9,6 +9,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; import classNames from 'classnames'; import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; +import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; +import { maxReactions } from '../../flavours/glitch/initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -27,6 +29,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + react: { id: 'status.react', defaultMessage: 'React' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, @@ -66,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent { relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, + onReactionAdd: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, @@ -127,6 +131,16 @@ class StatusActionBar extends ImmutablePureComponent { } } + handleEmojiPick = data => { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, '')); + } else { + this.props.onInteractionModal('favourite', this.props.status); + } + } + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -230,6 +244,8 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); } + nop = () => {} + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn } = this.context.identity; @@ -350,11 +366,23 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + + ); + return (
+ {shareButton} diff --git a/app/javascript/mastodon/components/status_reactions.js b/app/javascript/mastodon/components/status_reactions.js new file mode 100644 index 0000000000..39956270a4 --- /dev/null +++ b/app/javascript/mastodon/components/status_reactions.js @@ -0,0 +1,179 @@ +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { reduceMotion } from '../initial_state'; +import spring from 'react-motion/lib/spring'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import classNames from 'classnames'; +import React from 'react'; +import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; +import AnimatedNumber from './animated_number'; +import { assetHost } from '../utils/config'; +import { autoPlayGif } from '../initial_state'; + +export default class StatusReactions extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + numVisible: PropTypes.number, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + }; + + willEnter() { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave() { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render() { + const { reactions, numVisible } = this.props; + let visibleReactions = reactions + .filter(x => x.get('count') > 0) + .sort((a, b) => b.get('count') - a.get('count')); + + // numVisible might be NaN because it's pulled from local settings + // which doesn't do a whole lot of input validation, but that's okay + // because NaN >= 0 evaluates false. + // Still, this should be improved at some point. + if (numVisible >= 0) { + visibleReactions = visibleReactions.filter((_, i) => i < numVisible); + } + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render() { + const { reaction } = this.props; + + let shortCode = reaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + hovered: PropTypes.bool.isRequired, + }; + + render() { + const { emoji, emojiMap, hovered } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) + ? emojiMap.getIn([emoji, 'url']) + : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } else { + return null; + } + } + +} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 294105f259..556910f089 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; -import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; +import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors'; import { replyCompose, mentionCompose, @@ -16,6 +16,8 @@ import { unbookmark, pin, unpin, + addReaction, + removeReaction, } from '../actions/interactions'; import { muteStatus, @@ -66,6 +68,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), pictureInPicture: getPictureInPicture(state, props), + emojiMap: makeCustomEmojiMap(state), }); return mapStateToProps; @@ -129,6 +132,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name) { + dispatch(addReaction(statusId, name)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal('EMBED', { url: status.get('url'), diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 8cca8af2a8..efdbf9c908 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -319,6 +319,7 @@ class EmojiPickerDropdown extends React.PureComponent { onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, button: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -356,7 +357,7 @@ class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index a38f8d3c2d..3e91ebc369 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -6,6 +6,7 @@ import ClearColumnButton from './clear_column_button'; import GrantPermissionButton from './grant_permission_button'; import SettingToggle from './setting_toggle'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions'; +import PillBarButton from '../../../../flavours/glitch/features/notifications/components/pill_bar_button'; export default class ColumnSettings extends React.PureComponent { @@ -115,6 +116,17 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js index 368eb0b7e6..2b5f48f0b4 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.js +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js @@ -6,6 +6,7 @@ import Icon from 'mastodon/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent { > +
); - } else if (placeholder || number) { + } else if (placeholder) { return (
@@ -75,7 +73,6 @@ class Reaction extends ImmutablePureComponent { reaction: ImmutablePropTypes.map.isRequired, addReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, style: PropTypes.object, }; @@ -86,10 +83,12 @@ class Reaction extends ImmutablePureComponent { handleClick = () => { const { reaction, statusId, addReaction, removeReaction } = this.props; - if (reaction.get('me')) { - removeReaction(statusId, reaction.get('name')); - } else { - addReaction(statusId, reaction.get('name')); + if (!reaction.get('extern')) { + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } } } @@ -109,7 +108,12 @@ class Reaction extends ImmutablePureComponent { style={this.props.style} > - + @@ -124,12 +128,13 @@ class Emoji extends React.PureComponent { static propTypes = { emoji: PropTypes.string.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, }; render() { - const { emoji, emojiMap, hovered } = this.props; + const { emoji, hovered, url, staticUrl } = this.props; if (unicodeMapping[emoji]) { const { filename, shortCode } = unicodeMapping[this.props.emoji]; @@ -144,10 +149,8 @@ class Emoji extends React.PureComponent { src={`${assetHost}/emoji/${filename}.svg`} /> ); - } else if (emojiMap.get(emoji)) { - const filename = (autoPlayGif || hovered) - ? emojiMap.getIn([emoji, 'url']) - : emojiMap.getIn([emoji, 'static_url']); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; const shortCode = `:${emoji}:`; return ( @@ -159,8 +162,6 @@ class Emoji extends React.PureComponent { src={filename} /> ); - } else { - return null; } } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index c2fd15b17c..d68e059af4 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -45,7 +45,6 @@ import { showAlertForError } from '../actions/alerts'; import AccountContainer from 'flavours/glitch/containers/account_container'; import Spoilers from '../components/spoilers'; import Icon from 'flavours/glitch/components/icon'; -import { makeCustomEmojiMap } from '../selectors'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -85,7 +84,6 @@ const makeMapStateToProps = () => { account: account || props.account, settings: state.get('local_settings'), prepend: prepend || props.prepend, - emojiMap: makeCustomEmojiMap(state), pictureInPicture: { inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 619c57685a..1ed89ea059 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -203,7 +203,7 @@ class ActionBar extends React.PureComponent { } } - const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; const reactButton = (
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 1bbfe2b421..a33939445f 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -43,7 +43,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { initBoostModal } from 'flavours/glitch/actions/boosts'; -import { makeCustomEmojiMap, makeGetStatus } from 'flavours/glitch/selectors'; +import { makeGetStatus } from 'flavours/glitch/selectors'; import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ColumnHeader from '../../components/column_header'; @@ -148,7 +148,6 @@ const makeMapStateToProps = () => { askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, - emojiMap: makeCustomEmojiMap(state), }; }; @@ -707,7 +706,6 @@ class Status extends ImmutablePureComponent { showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} usingPiP={usingPiP} - emojiMap={this.props.emojiMap} /> { return hidden && !(isSelf || followingOrRequested); }); - -export const makeCustomEmojiMap = createSelector( - [state => state.get('custom_emojis')], - items => items.reduce( - (map, emoji) => map.set(emoji.get('shortcode'), emoji), - ImmutableMap(), - ), -); diff --git a/app/serializers/rest/reaction_serializer.rb b/app/serializers/rest/reaction_serializer.rb index 1a5dca0183..007d3b5015 100644 --- a/app/serializers/rest/reaction_serializer.rb +++ b/app/serializers/rest/reaction_serializer.rb @@ -3,7 +3,7 @@ class REST::ReactionSerializer < ActiveModel::Serializer include RoutingHelper - attributes :name, :count + attributes :name, :count, :extern attribute :me, if: :current_user? attribute :url, if: :custom_emoji? @@ -21,6 +21,14 @@ class REST::ReactionSerializer < ActiveModel::Serializer object.custom_emoji.present? end + def extern + if custom_emoji? + object.custom_emoji.domain.present? + else + false + end + end + def url full_asset_url(object.custom_emoji.image.url) end From e4949a252e2a50428299ca71cc555414aa54c9fc Mon Sep 17 00:00:00 2001 From: fef Date: Sat, 3 Dec 2022 14:23:55 +0000 Subject: [PATCH 35/48] run i18n-tasks normalize --- config/locales/de.yml | 8 ++++---- config/locales/en.yml | 8 ++++---- config/locales/en_GB.yml | 8 ++++---- config/locales/fr.yml | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/config/locales/de.yml b/config/locales/de.yml index 1899b131d1..e8b7261f4b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1285,10 +1285,6 @@ de: body: 'Dein Beitrag wurde von %{name} favorisiert:' subject: "%{name} hat deinen Beitrag favorisiert" title: Neue Favorisierung - reaction: - body: '%{name} hat auf deinen Beitrag reagiert:' - subject: '%{name} hat auf deinen Beitrag reagiert' - title: Neue Reaktion follow: body: "%{name} folgt dir jetzt!" subject: "%{name} folgt dir jetzt" @@ -1305,6 +1301,10 @@ de: title: Neue Erwähnung poll: subject: Eine Umfrage von %{name} ist beendet + reaction: + body: "%{name} hat auf deinen Beitrag reagiert:" + subject: "%{name} hat auf deinen Beitrag reagiert" + title: Neue Reaktion reblog: body: 'Deinen Beitrag hat %{name} geteilt:' subject: "%{name} hat deinen Beitrag geteilt" diff --git a/config/locales/en.yml b/config/locales/en.yml index bf43d0c752..f3bf0b6463 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1292,10 +1292,6 @@ en: body: 'Your post was favourited by %{name}:' subject: "%{name} favourited your post" title: New favourite - reaction: - body: '%{name} reacted to your post:' - subject: '%{name} reacted to your post' - title: New reaction follow: body: "%{name} is now following you!" subject: "%{name} is now following you" @@ -1312,6 +1308,10 @@ en: title: New mention poll: subject: A poll by %{name} has ended + reaction: + body: "%{name} reacted to your post:" + subject: "%{name} reacted to your post" + title: New reaction reblog: body: 'Your post was boosted by %{name}:' subject: "%{name} boosted your post" diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 80084d9a6f..506d462409 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -707,10 +707,6 @@ en_GB: body: 'Your status was favourited by %{name}:' subject: "%{name} favourited your status" title: New favourite - reaction: - body: '%{name} reacted on your post with %{reaction}:' - subject: '%{name} reacted on your post' - title: New reaction follow: body: "%{name} is now following you!" subject: "%{name} is now following you" @@ -725,6 +721,10 @@ en_GB: body: 'You were mentioned by %{name} in:' subject: You were mentioned by %{name} title: New mention + reaction: + body: "%{name} reacted on your post with %{reaction}:" + subject: "%{name} reacted on your post" + title: New reaction reblog: body: 'Your status was boosted by %{name}:' subject: "%{name} boosted your status" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 9f3cf843a1..e1deaab221 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1285,10 +1285,6 @@ fr: body: "%{name} a ajouté votre message à ses favoris :" subject: "%{name} a ajouté votre message à ses favoris" title: Nouveau favori - reaction: - body: '%{name} a réagi·e à votre message:' - subject: '%{name} a réagi·e à votre message' - title: Nouvelle réaction follow: body: "%{name} vous suit !" subject: "%{name} vous suit" @@ -1305,6 +1301,10 @@ fr: title: Nouvelle mention poll: subject: Un sondage de %{name} est terminé + reaction: + body: "%{name} a réagi·e à votre message:" + subject: "%{name} a réagi·e à votre message" + title: Nouvelle réaction reblog: body: 'Votre message été partagé par %{name} :' subject: "%{name} a partagé votre message" From 47ac7a2e502f70fbe3481b9c4e82f3eec8b66b45 Mon Sep 17 00:00:00 2001 From: fef Date: Sat, 3 Dec 2022 16:20:29 +0000 Subject: [PATCH 36/48] fix image for new custom emoji reactions --- .../flavours/glitch/actions/interactions.js | 7 ++++--- .../flavours/glitch/components/status_action_bar.js | 2 +- .../flavours/glitch/containers/status_container.js | 4 ++-- .../glitch/features/status/components/action_bar.js | 2 +- .../flavours/glitch/features/status/index.js | 4 ++-- app/javascript/flavours/glitch/reducers/statuses.js | 12 +++++++++--- app/javascript/mastodon/actions/interactions.js | 7 ++++--- .../mastodon/containers/status_container.js | 4 ++-- app/javascript/mastodon/features/status/index.js | 4 ++-- app/javascript/mastodon/reducers/statuses.js | 12 +++++++++--- 10 files changed, 36 insertions(+), 22 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 0df6db4277..2d483de818 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -403,7 +403,7 @@ export function unpinFail(status, error) { }; }; -export const addReaction = (statusId, name) => (dispatch, getState) => { +export const addReaction = (statusId, name, url) => (dispatch, getState) => { const status = getState().get('statuses').get(statusId); let alreadyAdded = false; if (status) { @@ -413,7 +413,7 @@ export const addReaction = (statusId, name) => (dispatch, getState) => { } } if (!alreadyAdded) { - dispatch(addReactionRequest(statusId, name)); + dispatch(addReactionRequest(statusId, name, url)); } api(getState).post(`/api/v1/statuses/${statusId}/react/${name}`).then(() => { @@ -425,10 +425,11 @@ export const addReaction = (statusId, name) => (dispatch, getState) => { }); }; -export const addReactionRequest = (statusId, name) => ({ +export const addReactionRequest = (statusId, name, url) => ({ type: REACTION_ADD_REQUEST, id: statusId, name, + url, }); export const addReactionSuccess = (statusId, name) => ({ diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 33dbe3ab89..72912acedd 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -118,7 +118,7 @@ class StatusActionBar extends ImmutablePureComponent { } handleEmojiPick = data => { - this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, '')); + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); } handleReblogClick = e => { diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index d68e059af4..3edcf9c7a8 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -167,8 +167,8 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, - onReactionAdd (statusId, name) { - dispatch(addReaction(statusId, name)); + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); }, onReactionRemove (statusId, name) { diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 1ed89ea059..6f86e2aa2c 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -81,7 +81,7 @@ class ActionBar extends React.PureComponent { } handleEmojiPick = data => { - this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, '')); + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); } handleBookmarkClick = (e) => { diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index a33939445f..f77f4a20fb 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -293,12 +293,12 @@ class Status extends ImmutablePureComponent { } } - handleReactionAdd = (statusId, name) => { + handleReactionAdd = (statusId, name, url) => { const { dispatch } = this.props; const { signedIn } = this.context.identity; if (signedIn) { - dispatch(addReaction(statusId, name)); + dispatch(addReaction(statusId, name, url)); } else { dispatch(openModal('INTERACTION', { type: 'reaction_add', diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 22fdeb2841..446c991cac 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -59,11 +59,17 @@ const updateReaction = (state, id, name, updater) => state.update( const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count)); -const addReaction = (state, id, name) => updateReaction( +// The url parameter is only used when adding a new custom emoji reaction +// (one that wasn't in the reactions list before) because we don't have its +// URL yet. In all other cases, it's undefined. +const addReaction = (state, id, name, url) => updateReaction( state, id, name, - x => x.set('me', true).update('count', n => n + 1), + x => x.set('me', true) + .update('count', n => n + 1) + .update('url', old => old ? old : url) + .update('static_url', old => old ? old : url), ); const removeReaction = (state, id, name) => updateReaction( @@ -103,7 +109,7 @@ export default function statuses(state = initialState, action) { return updateReactionCount(state, action.reaction); case REACTION_ADD_REQUEST: case REACTION_REMOVE_FAIL: - return addReaction(state, action.id, action.name); + return addReaction(state, action.id, action.name, action.url); case REACTION_REMOVE_REQUEST: case REACTION_ADD_FAIL: return removeReaction(state, action.id, action.name); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index dd1395cbd3..54592ec535 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -423,7 +423,7 @@ export function unpinFail(status, error) { }; }; -export const addReaction = (statusId, name) => (dispatch, getState) => { +export const addReaction = (statusId, name, url) => (dispatch, getState) => { const status = getState().get('statuses').get(statusId); let alreadyAdded = false; if (status) { @@ -433,7 +433,7 @@ export const addReaction = (statusId, name) => (dispatch, getState) => { } } if (!alreadyAdded) { - dispatch(addReactionRequest(statusId, name)); + dispatch(addReactionRequest(statusId, name, url)); } api(getState).post(`/api/v1/statuses/${statusId}/react/${name}`).then(() => { @@ -445,10 +445,11 @@ export const addReaction = (statusId, name) => (dispatch, getState) => { }); }; -export const addReactionRequest = (statusId, name) => ({ +export const addReactionRequest = (statusId, name, url) => ({ type: REACTION_ADD_REQUEST, id: statusId, name, + url, }); export const addReactionSuccess = (statusId, name) => ({ diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 556910f089..70adc04934 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -132,8 +132,8 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, - onReactionAdd (statusId, name) { - dispatch(addReaction(statusId, name)); + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); }, onReactionRemove (statusId, name) { diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index e262cd94fb..d22009d0ed 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -257,12 +257,12 @@ class Status extends ImmutablePureComponent { } } - handleReactionAdd = (statusId, name) => { + handleReactionAdd = (statusId, name, url) => { const { dispatch } = this.props; const { signedIn } = this.context.identity; if (signedIn) { - dispatch(addReaction(statusId, name)); + dispatch(addReaction(statusId, name, url)); } else { dispatch(openModal('INTERACTION', { type: 'reaction_add', diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 2228906674..cb9759119a 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -57,11 +57,17 @@ const updateReaction = (state, id, name, updater) => state.update( const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count)); -const addReaction = (state, id, name) => updateReaction( +// The url parameter is only used when adding a new custom emoji reaction +// (one that wasn't in the reactions list before) because we don't have its +// URL yet. In all other cases, it's undefined. +const addReaction = (state, id, name, url) => updateReaction( state, id, name, - x => x.set('me', true).update('count', n => n + 1), + x => x.set('me', true) + .update('count', n => n + 1) + .update('url', old => old ? old : url) + .update('static_url', old => old ? old : url), ); const removeReaction = (state, id, name) => updateReaction( @@ -101,7 +107,7 @@ export default function statuses(state = initialState, action) { return updateReactionCount(state, action.reaction); case REACTION_ADD_REQUEST: case REACTION_REMOVE_FAIL: - return addReaction(state, action.id, action.name); + return addReaction(state, action.id, action.name, action.url); case REACTION_REMOVE_REQUEST: case REACTION_ADD_FAIL: return removeReaction(state, action.id, action.name); From e05874cffd20e57fda6bc56b0c714786701674cd Mon Sep 17 00:00:00 2001 From: fef Date: Sat, 3 Dec 2022 16:55:37 +0000 Subject: [PATCH 37/48] disable reaction button when not signed in --- .../flavours/glitch/containers/status_container.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 3edcf9c7a8..93a92c1377 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -168,7 +168,11 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ }, onReactionAdd (statusId, name, url) { - dispatch(addReaction(statusId, name, url)); + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(addReaction(statusId, name, url)); + } }, onReactionRemove (statusId, name) { From a556d81cde6c1e9971ff7413a947b2f6e97b08c9 Mon Sep 17 00:00:00 2001 From: fef Date: Sun, 4 Dec 2022 08:47:24 +0000 Subject: [PATCH 38/48] also disable reaction buttons in vanilla flavour --- .../mastodon/components/status_action_bar.js | 2 +- .../mastodon/components/status_reactions.js | 13 ++++++++----- .../features/status/components/action_bar.js | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 01d64062da..6695003cb2 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -365,7 +365,7 @@ class StatusActionBar extends ImmutablePureComponent { ); - const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; const reactButton = ( { const { reaction, statusId, addReaction, removeReaction } = this.props; - - if (reaction.get('me')) { - removeReaction(statusId, reaction.get('name')); - } else { - addReaction(statusId, reaction.get('name')); + const { signedIn } = this.context.identity; + + if (signedIn) { + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } } } diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 095bccfa8a..9ab228bdec 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -259,7 +259,7 @@ class ActionBar extends React.PureComponent { } } - const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; const reactButton = ( Date: Sun, 4 Dec 2022 10:52:02 +0000 Subject: [PATCH 39/48] serialize custom emoji reactions properly for AP Akkoma and possibly others expect the `tag` field in an EmojiReact activity to be an array, not just a single object, so it's being wrapped into one now. I'm not entirely sure whether this is the idiomatic way of doing it tbh, but it works fine. --- app/serializers/activitypub/emoji_reaction_serializer.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/serializers/activitypub/emoji_reaction_serializer.rb b/app/serializers/activitypub/emoji_reaction_serializer.rb index 50f3efa3cb..f8887f15b7 100644 --- a/app/serializers/activitypub/emoji_reaction_serializer.rb +++ b/app/serializers/activitypub/emoji_reaction_serializer.rb @@ -3,8 +3,7 @@ class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer attributes :id, :type, :actor, :content attribute :virtual_object, key: :object - - has_one :custom_emoji, key: :tag, serializer: ActivityPub::EmojiSerializer, unless: -> { object.custom_emoji.nil? } + attribute :custom_emoji, key: :tag, unless: -> { object.custom_emoji.nil? } def id [ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id].join @@ -31,4 +30,10 @@ class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer end alias reaction content + + # Akkoma (and possibly others) expect `tag` to be an array, so we can't just + # use the has_one shorthand because we need to wrap it into an array manually + def custom_emoji + [ActivityPub::EmojiSerializer.new(object.custom_emoji).serializable_hash] + end end From 18e408c6af3d2f18ad5ec3ea3f4242933b76f6fa Mon Sep 17 00:00:00 2001 From: fef Date: Sun, 4 Dec 2022 12:33:47 +0000 Subject: [PATCH 40/48] properly disable reactions when not logged in --- .../flavours/glitch/components/status.js | 2 + .../glitch/components/status_action_bar.js | 6 ++- .../glitch/components/status_reactions.js | 14 ++++--- .../glitch/containers/status_container.js | 6 +-- .../features/status/components/action_bar.js | 8 +++- .../status/components/detailed_status.js | 2 + .../flavours/glitch/features/status/index.js | 6 --- app/javascript/mastodon/components/status.js | 3 +- .../mastodon/components/status_action_bar.js | 8 +--- .../mastodon/components/status_reactions.js | 40 +++++++++---------- .../features/status/components/action_bar.js | 8 +++- .../status/components/detailed_status.js | 4 +- .../mastodon/features/status/index.js | 6 --- 13 files changed, 57 insertions(+), 56 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 08cfbdada0..99ed644aa7 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -62,6 +62,7 @@ class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -810,6 +811,7 @@ class Status extends ImmutablePureComponent { numVisible={visibleReactions} addReaction={this.props.onReactionAdd} removeReaction={this.props.onReactionRemove} + canReact={this.context.identity.signedIn} /> {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 72912acedd..bb350ea5a2 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -331,7 +331,11 @@ class StatusActionBar extends ImmutablePureComponent { /> - + { + signedIn + ? + : reactButton + } {shareButton} diff --git a/app/javascript/flavours/glitch/components/status_reactions.js b/app/javascript/flavours/glitch/components/status_reactions.js index e263a64809..ff025e8d28 100644 --- a/app/javascript/flavours/glitch/components/status_reactions.js +++ b/app/javascript/flavours/glitch/components/status_reactions.js @@ -17,6 +17,7 @@ export default class StatusReactions extends ImmutablePureComponent { reactions: ImmutablePropTypes.list.isRequired, numVisible: PropTypes.number, addReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, removeReaction: PropTypes.func.isRequired, }; @@ -56,6 +57,7 @@ export default class StatusReactions extends ImmutablePureComponent { style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} addReaction={this.props.addReaction} removeReaction={this.props.removeReaction} + canReact={this.props.canReact} /> ))}
@@ -73,6 +75,7 @@ class Reaction extends ImmutablePureComponent { reaction: ImmutablePropTypes.map.isRequired, addReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, style: PropTypes.object, }; @@ -83,12 +86,10 @@ class Reaction extends ImmutablePureComponent { handleClick = () => { const { reaction, statusId, addReaction, removeReaction } = this.props; - if (!reaction.get('extern')) { - if (reaction.get('me')) { - removeReaction(statusId, reaction.get('name')); - } else { - addReaction(statusId, reaction.get('name')); - } + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); } } @@ -105,6 +106,7 @@ class Reaction extends ImmutablePureComponent { onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} + disabled={!this.props.canReact} style={this.props.style} > diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 93a92c1377..3edcf9c7a8 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -168,11 +168,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ }, onReactionAdd (statusId, name, url) { - const { signedIn } = this.context.identity; - - if (signedIn) { - dispatch(addReaction(statusId, name, url)); - } + dispatch(addReaction(statusId, name, url)); }, onReactionRemove (statusId, name) { diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 6f86e2aa2c..39d32178ae 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -236,7 +236,13 @@ class ActionBar extends React.PureComponent {
-
+
+ { + signedIn + ? + : reactButton + } +
{shareButton}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index a3d6150b7b..87eb463d3b 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -27,6 +27,7 @@ class DetailedStatus extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -327,6 +328,7 @@ class DetailedStatus extends ImmutablePureComponent { reactions={status.get('reactions')} addReaction={this.props.onReactionAdd} removeReaction={this.props.onReactionRemove} + canReact={this.context.identity.signedIn} />
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index f77f4a20fb..50f6274403 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -299,12 +299,6 @@ class Status extends ImmutablePureComponent { if (signedIn) { dispatch(addReaction(statusId, name, url)); - } else { - dispatch(openModal('INTERACTION', { - type: 'reaction_add', - accountId: status.getIn(['account', 'id']), - url: status.get('url'), - })); } } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 284c5dbd78..367131efe5 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -65,6 +65,7 @@ class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -547,7 +548,7 @@ class Status extends ImmutablePureComponent { numVisible={visibleReactions} addReaction={this.props.onReactionAdd} removeReaction={this.props.onReactionRemove} - emojiMap={this.props.emojiMap} + canReact={this.context.identity.signedIn} /> diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 6695003cb2..b2ad54cb58 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -131,13 +131,7 @@ class StatusActionBar extends ImmutablePureComponent { } handleEmojiPick = data => { - const { signedIn } = this.context.identity; - - if (signedIn) { - this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, '')); - } else { - this.props.onInteractionModal('favourite', this.props.status); - } + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); } handleReblogClick = e => { diff --git a/app/javascript/mastodon/components/status_reactions.js b/app/javascript/mastodon/components/status_reactions.js index c16b7e8260..ff025e8d28 100644 --- a/app/javascript/mastodon/components/status_reactions.js +++ b/app/javascript/mastodon/components/status_reactions.js @@ -17,8 +17,8 @@ export default class StatusReactions extends ImmutablePureComponent { reactions: ImmutablePropTypes.list.isRequired, numVisible: PropTypes.number, addReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, }; willEnter() { @@ -57,7 +57,7 @@ export default class StatusReactions extends ImmutablePureComponent { style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} addReaction={this.props.addReaction} removeReaction={this.props.removeReaction} - emojiMap={this.props.emojiMap} + canReact={this.props.canReact} /> ))}
@@ -75,7 +75,7 @@ class Reaction extends ImmutablePureComponent { reaction: ImmutablePropTypes.map.isRequired, addReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, + canReact: PropTypes.bool.isRequired, style: PropTypes.object, }; @@ -85,14 +85,11 @@ class Reaction extends ImmutablePureComponent { handleClick = () => { const { reaction, statusId, addReaction, removeReaction } = this.props; - const { signedIn } = this.context.identity; - - if (signedIn) { - if (reaction.get('me')) { - removeReaction(statusId, reaction.get('name')); - } else { - addReaction(statusId, reaction.get('name')); - } + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); } } @@ -109,10 +106,16 @@ class Reaction extends ImmutablePureComponent { onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} + disabled={!this.props.canReact} style={this.props.style} > - + @@ -127,12 +130,13 @@ class Emoji extends React.PureComponent { static propTypes = { emoji: PropTypes.string.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, }; render() { - const { emoji, emojiMap, hovered } = this.props; + const { emoji, hovered, url, staticUrl } = this.props; if (unicodeMapping[emoji]) { const { filename, shortCode } = unicodeMapping[this.props.emoji]; @@ -147,10 +151,8 @@ class Emoji extends React.PureComponent { src={`${assetHost}/emoji/${filename}.svg`} /> ); - } else if (emojiMap.get(emoji)) { - const filename = (autoPlayGif || hovered) - ? emojiMap.getIn([emoji, 'url']) - : emojiMap.getIn([emoji, 'static_url']); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; const shortCode = `:${emoji}:`; return ( @@ -162,8 +164,6 @@ class Emoji extends React.PureComponent { src={filename} /> ); - } else { - return null; } } diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 9ab228bdec..91a8a7793e 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -299,7 +299,13 @@ class ActionBar extends React.PureComponent {
-
+
+ { + canReact + ? + : reactButton + } +
{shareButton} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index f34b66110f..acb9fff572 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -31,6 +31,7 @@ class DetailedStatus extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -51,7 +52,6 @@ class DetailedStatus extends ImmutablePureComponent { onToggleMediaVisibility: PropTypes.func, onReactionAdd: PropTypes.func.isRequired, onReactionRemove: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, }; state = { @@ -284,7 +284,7 @@ class DetailedStatus extends ImmutablePureComponent { reactions={status.get('reactions')} addReaction={this.props.onReactionAdd} removeReaction={this.props.onReactionRemove} - emojiMap={this.props.emojiMap} + canReact={this.context.identity.signedIn} />
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index d22009d0ed..a3a0f0af77 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -263,12 +263,6 @@ class Status extends ImmutablePureComponent { if (signedIn) { dispatch(addReaction(statusId, name, url)); - } else { - dispatch(openModal('INTERACTION', { - type: 'reaction_add', - accountId: status.getIn(['account', 'id']), - url: status.get('url'), - })); } } From e0637a3c1dcd28979d2c0e0911efe09a01d1cfec Mon Sep 17 00:00:00 2001 From: fef Date: Wed, 7 Dec 2022 12:19:36 +0000 Subject: [PATCH 41/48] support reacting with foreign custom emojis --- app/models/status_reaction.rb | 7 +------ app/serializers/rest/reaction_serializer.rb | 16 +++++++++++----- app/validators/status_reaction_validator.rb | 4 ---- config/routes.rb | 6 ++++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/models/status_reaction.rb b/app/models/status_reaction.rb index 4793ff9aa3..0833a7eafd 100644 --- a/app/models/status_reaction.rb +++ b/app/models/status_reaction.rb @@ -24,11 +24,6 @@ class StatusReaction < ApplicationRecord private def set_custom_emoji - return if name.blank? - self.custom_emoji = if account.local? - CustomEmoji.local.find_by(disabled: false, shortcode: name) - else - CustomEmoji.find_by(shortcode: name, domain: account.domain) - end + self.custom_emoji = CustomEmoji.find_by(shortcode: name, domain: account.domain) if name.blank? end end diff --git a/app/serializers/rest/reaction_serializer.rb b/app/serializers/rest/reaction_serializer.rb index 007d3b5015..b0f0732bf7 100644 --- a/app/serializers/rest/reaction_serializer.rb +++ b/app/serializers/rest/reaction_serializer.rb @@ -3,7 +3,7 @@ class REST::ReactionSerializer < ActiveModel::Serializer include RoutingHelper - attributes :name, :count, :extern + attributes :name, :count attribute :me, if: :current_user? attribute :url, if: :custom_emoji? @@ -21,11 +21,11 @@ class REST::ReactionSerializer < ActiveModel::Serializer object.custom_emoji.present? end - def extern - if custom_emoji? - object.custom_emoji.domain.present? + def name + if extern? + [object.name, '@', object.custom_emoji.domain].join else - false + object.name end end @@ -36,4 +36,10 @@ class REST::ReactionSerializer < ActiveModel::Serializer def static_url full_asset_url(object.custom_emoji.image.url(:static)) end + + private + + def extern? + custom_emoji? && object.custom_emoji.domain.present? + end end diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb index a60271dd84..d85d48e4c7 100644 --- a/app/validators/status_reaction_validator.rb +++ b/app/validators/status_reaction_validator.rb @@ -18,10 +18,6 @@ class StatusReactionValidator < ActiveModel::Validator SUPPORTED_EMOJIS.include?(name) end - def new_reaction?(reaction) - !reaction.status.status_reactions.where(name: reaction.name).exists? - end - def limit_reached?(reaction) reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT end diff --git a/config/routes.rb b/config/routes.rb index 2eeff8fcbb..87634a7f54 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -442,8 +442,10 @@ Rails.application.routes.draw do resource :favourite, only: :create post :unfavourite, to: 'favourites#destroy' - post '/react/:id', to: 'reactions#create' - post '/unreact/:id', to: 'reactions#destroy' + # foreign custom emojis are encoded as shortcode@domain.tld + # the constraint prevents rails from interpreting the ".tld" as a filename extension + post '/react/:id', to: 'reactions#create', constraints: { id: /[^\/]+/ } + post '/unreact/:id', to: 'reactions#destroy', constraints: { id: /[^\/]+/ } resource :bookmark, only: :create post :unbookmark, to: 'bookmarks#destroy' From 4b6e75512f444695c33302de01759491f1150861 Mon Sep 17 00:00:00 2001 From: fef Date: Wed, 7 Dec 2022 12:47:03 +0000 Subject: [PATCH 42/48] delete reaction notifications when deleting status --- app/models/status_reaction.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/status_reaction.rb b/app/models/status_reaction.rb index 0833a7eafd..00be17e231 100644 --- a/app/models/status_reaction.rb +++ b/app/models/status_reaction.rb @@ -16,6 +16,8 @@ class StatusReaction < ApplicationRecord belongs_to :status, inverse_of: :status_reactions belongs_to :custom_emoji, optional: true + has_one :notification, as: :activity, dependent: :destroy + validates :name, presence: true validates_with StatusReactionValidator From fd2a11607160b54d60c82553efb39f89da96d936 Mon Sep 17 00:00:00 2001 From: fef Date: Wed, 7 Dec 2022 21:52:53 +0100 Subject: [PATCH 43/48] fix schema after rebase --- db/schema.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/db/schema.rb b/db/schema.rb index 7462bf2c7d..bb685a9dbd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -897,6 +897,19 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) do t.index ["status_id"], name: "index_status_pins_on_status_id" end + create_table "status_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.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id", "status_id", "name"], name: "index_status_reactions_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_status_reactions_on_account_id" + t.index ["custom_emoji_id"], name: "index_status_reactions_on_custom_emoji_id" + t.index ["status_id"], name: "index_status_reactions_on_status_id" + end + create_table "status_stats", force: :cascade do |t| t.bigint "status_id", null: false t.bigint "replies_count", default: 0, null: false @@ -1211,6 +1224,9 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) do add_foreign_key "status_edits", "statuses", on_delete: :cascade add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade add_foreign_key "status_pins", "statuses", on_delete: :cascade + add_foreign_key "status_reactions", "accounts", on_delete: :cascade + add_foreign_key "status_reactions", "custom_emojis", on_delete: :cascade + add_foreign_key "status_reactions", "statuses", on_delete: :cascade add_foreign_key "status_stats", "statuses", on_delete: :cascade add_foreign_key "status_trends", "accounts", on_delete: :cascade add_foreign_key "status_trends", "statuses", on_delete: :cascade From c739d89b0c903e906dd7a4d06b46b5a1d575db86 Mon Sep 17 00:00:00 2001 From: fef Date: Thu, 8 Dec 2022 09:48:55 +0000 Subject: [PATCH 44/48] fix status action bar after upstream changes --- app/javascript/mastodon/components/status_action_bar.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index b2ad54cb58..45300ddea8 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -375,7 +375,11 @@ class StatusActionBar extends ImmutablePureComponent { - + { + signedIn + ? + : reactButton + } {shareButton} From 6cad7718ebd3bfded3500f09659308ab048b9c0f Mon Sep 17 00:00:00 2001 From: fef Date: Sun, 11 Dec 2022 13:26:23 +0000 Subject: [PATCH 45/48] fix 404 when reacting with Keycap Number Sign The Unicode sequence for this emoji starts with an ASCII # character, which the browser's URI parser truncates before sending the request to the backend. --- app/javascript/flavours/glitch/actions/interactions.js | 6 ++++-- app/javascript/mastodon/actions/interactions.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 2d483de818..a7eb7a78dd 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -416,7 +416,9 @@ export const addReaction = (statusId, name, url) => (dispatch, getState) => { dispatch(addReactionRequest(statusId, name, url)); } - api(getState).post(`/api/v1/statuses/${statusId}/react/${name}`).then(() => { + // encodeURIComponent is required for the Keycap Number Sign emoji, see: + // + api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => { dispatch(addReactionSuccess(statusId, name)); }).catch(err => { if (!alreadyAdded) { @@ -448,7 +450,7 @@ export const addReactionFail = (statusId, name, error) => ({ export const removeReaction = (statusId, name) => (dispatch, getState) => { dispatch(removeReactionRequest(statusId, name)); - api(getState).post(`/api/v1/statuses/${statusId}/unreact/${name}`).then(() => { + api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => { dispatch(removeReactionSuccess(statusId, name)); }).catch(err => { dispatch(removeReactionFail(statusId, name, err)); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 54592ec535..7433870250 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -436,7 +436,9 @@ export const addReaction = (statusId, name, url) => (dispatch, getState) => { dispatch(addReactionRequest(statusId, name, url)); } - api(getState).post(`/api/v1/statuses/${statusId}/react/${name}`).then(() => { + // encodeURIComponent is required for the Keycap Number Sign emoji, see: + // + api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => { dispatch(addReactionSuccess(statusId, name)); }).catch(err => { if (!alreadyAdded) { @@ -468,7 +470,7 @@ export const addReactionFail = (statusId, name, error) => ({ export const removeReaction = (statusId, name) => (dispatch, getState) => { dispatch(removeReactionRequest(statusId, name)); - api(getState).post(`/api/v1/statuses/${statusId}/unreact/${name}`).then(() => { + api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => { dispatch(removeReactionSuccess(statusId, name)); }).catch(err => { dispatch(removeReactionFail(statusId, name, err)); From c27dcb40e15cf662e13285a3ffb7a4ea686760dd Mon Sep 17 00:00:00 2001 From: fef Date: Thu, 15 Dec 2022 15:27:54 +0000 Subject: [PATCH 46/48] bypass reaction limit for foreign accounts --- app/validators/status_reaction_validator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/validators/status_reaction_validator.rb b/app/validators/status_reaction_validator.rb index d85d48e4c7..8c623c823d 100644 --- a/app/validators/status_reaction_validator.rb +++ b/app/validators/status_reaction_validator.rb @@ -9,7 +9,7 @@ class StatusReactionValidator < ActiveModel::Validator return if reaction.name.blank? reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name) - reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if limit_reached?(reaction) + reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if reaction.account.local? && limit_reached?(reaction) end private From bf139fefa1d6fe33971c7d49e3f662ee55f544df Mon Sep 17 00:00:00 2001 From: Jeremy Kescher Date: Sun, 18 Dec 2022 04:23:42 +0100 Subject: [PATCH 47/48] Fix status reactions preventing an on_cascade delete --- db/migrate/20221124114030_create_status_reactions.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/migrate/20221124114030_create_status_reactions.rb b/db/migrate/20221124114030_create_status_reactions.rb index bbf1f3376a..5f010c4a0b 100644 --- a/db/migrate/20221124114030_create_status_reactions.rb +++ b/db/migrate/20221124114030_create_status_reactions.rb @@ -1,10 +1,10 @@ class CreateStatusReactions < ActiveRecord::Migration[6.1] def change create_table :status_reactions do |t| - t.references :account, null: false, foreign_key: true - t.references :status, null: false, foreign_key: true + t.references :account, null: false, foreign_key: { on_delete: :cascade } + t.references :status, null: false, foreign_key: { on_delete: :cascade } t.string :name, null: false, default: '' - t.references :custom_emoji, null: true, foreign_key: true + t.references :custom_emoji, null: true, foreign_key: { on_delete: :cascade } t.timestamps end From 598f3b9b31877c917655cbe2d06c81057952ab81 Mon Sep 17 00:00:00 2001 From: fef Date: Tue, 20 Dec 2022 17:19:56 +0000 Subject: [PATCH 48/48] move emoji reaction strings to locales-glitch --- config/locales-glitch/de.yml | 7 +++++++ config/locales-glitch/en.yml | 5 +++++ config/locales-glitch/fr.yml | 6 ++++++ config/locales-glitch/simple_form.de.yml | 6 ++++++ config/locales-glitch/simple_form.en.yml | 1 + config/locales-glitch/simple_form.fr.yml | 6 ++++++ config/locales/de.yml | 4 ---- config/locales/en.yml | 4 ---- config/locales/fr.yml | 4 ---- config/locales/simple_form.de.yml | 1 - config/locales/simple_form.en.yml | 1 - config/locales/simple_form.fr.yml | 1 - 12 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 config/locales-glitch/de.yml create mode 100644 config/locales-glitch/fr.yml create mode 100644 config/locales-glitch/simple_form.de.yml create mode 100644 config/locales-glitch/simple_form.fr.yml diff --git a/config/locales-glitch/de.yml b/config/locales-glitch/de.yml new file mode 100644 index 0000000000..77ac5037f5 --- /dev/null +++ b/config/locales-glitch/de.yml @@ -0,0 +1,7 @@ +--- +de: + notification_mailer: + reaction: + body: "%{name} hat auf deinen Beitrag reagiert:" + subject: "%{name} hat auf deinen Beitrag reagiert" + title: Neue Reaktion diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml index 23c566153e..6f2d5f18ec 100644 --- a/config/locales-glitch/en.yml +++ b/config/locales-glitch/en.yml @@ -39,3 +39,8 @@ en: use_this: Use this settings: flavours: Flavours + notification_mailer: + reaction: + body: "%{name} reacted to your post:" + subject: "%{name} reacted to your post" + title: New reaction diff --git a/config/locales-glitch/fr.yml b/config/locales-glitch/fr.yml new file mode 100644 index 0000000000..51c50d7dbd --- /dev/null +++ b/config/locales-glitch/fr.yml @@ -0,0 +1,6 @@ +fr: + notification_mailer: + reaction: + body: "%{name} a réagi·e à votre message:" + subject: "%{name} a réagi·e à votre message" + title: Nouvelle réaction diff --git a/config/locales-glitch/simple_form.de.yml b/config/locales-glitch/simple_form.de.yml new file mode 100644 index 0000000000..68061f5a58 --- /dev/null +++ b/config/locales-glitch/simple_form.de.yml @@ -0,0 +1,6 @@ +--- +de: + simple_form: + labels: + default: + setting_visible_reactions: Anzahl der sichtbaren Emoji-Reaktionen diff --git a/config/locales-glitch/simple_form.en.yml b/config/locales-glitch/simple_form.en.yml index c6e7d54ca0..e7c0d38c4a 100644 --- a/config/locales-glitch/simple_form.en.yml +++ b/config/locales-glitch/simple_form.en.yml @@ -21,6 +21,7 @@ en: setting_hide_followers_count: Hide your followers count setting_skin: Skin setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only) + setting_visible_reactions: Number of visible emoji reactions notification_emails: trending_tag: New trending tag requires review trending_link: New trending link requires review diff --git a/config/locales-glitch/simple_form.fr.yml b/config/locales-glitch/simple_form.fr.yml new file mode 100644 index 0000000000..c69548ca12 --- /dev/null +++ b/config/locales-glitch/simple_form.fr.yml @@ -0,0 +1,6 @@ +--- +fr: + simple_form: + labels: + default: + setting_visible_reactions: Nombre de réactions emoji visibles diff --git a/config/locales/de.yml b/config/locales/de.yml index e8b7261f4b..8a12a30470 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1301,10 +1301,6 @@ de: title: Neue Erwähnung poll: subject: Eine Umfrage von %{name} ist beendet - reaction: - body: "%{name} hat auf deinen Beitrag reagiert:" - subject: "%{name} hat auf deinen Beitrag reagiert" - title: Neue Reaktion reblog: body: 'Deinen Beitrag hat %{name} geteilt:' subject: "%{name} hat deinen Beitrag geteilt" diff --git a/config/locales/en.yml b/config/locales/en.yml index f3bf0b6463..075ce2136f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1308,10 +1308,6 @@ en: title: New mention poll: subject: A poll by %{name} has ended - reaction: - body: "%{name} reacted to your post:" - subject: "%{name} reacted to your post" - title: New reaction reblog: body: 'Your post was boosted by %{name}:' subject: "%{name} boosted your post" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index e1deaab221..b24603db98 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1301,10 +1301,6 @@ fr: title: Nouvelle mention poll: subject: Un sondage de %{name} est terminé - reaction: - body: "%{name} a réagi·e à votre message:" - subject: "%{name} a réagi·e à votre message" - title: Nouvelle réaction reblog: body: 'Votre message été partagé par %{name} :' subject: "%{name} a partagé votre message" diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index e9356c6eae..f12211c94f 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -213,7 +213,6 @@ de: setting_unfollow_modal: Bestätigungsdialog anzeigen, bevor jemandem entfolgt wird setting_use_blurhash: Farbverlauf für verborgene Medien anzeigen setting_use_pending_items: Langsamer Modus - setting_visible_reactions: Anzahl der sichtbaren Emoji-Reaktionen severity: Schweregrad sign_in_token_attempt: Sicherheitscode title: Titel diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 880d9190cd..6edf7b4e9e 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -213,7 +213,6 @@ en: setting_unfollow_modal: Show confirmation dialog before unfollowing someone setting_use_blurhash: Show colorful gradients for hidden media setting_use_pending_items: Slow mode - setting_visible_reactions: Number of visible emoji reactions severity: Severity sign_in_token_attempt: Security code title: Title diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index b3f56d60b0..73fb4f3347 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -213,7 +213,6 @@ fr: setting_unfollow_modal: Afficher une fenêtre de confirmation avant de vous désabonner d’un compte setting_use_blurhash: Afficher des dégradés colorés pour les médias cachés setting_use_pending_items: Mode lent - setting_visible_reactions: Nombre de réactions emoji visibles severity: Sévérité sign_in_token_attempt: Code de sécurité title: Nom