Compare commits

...

2 Commits

Author SHA1 Message Date
anna 9172df2fdf
add frontend for emoji reactions
this is still pretty bare bones but hey, it works.
1 year ago
anna 6a251a7dd8
add backend support for status emoji reactions
turns out we can just reuse the code for
announcement reactions.
1 year ago

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

@ -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,
});

@ -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,
@ -101,6 +104,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,
@ -794,6 +798,14 @@ class Status extends ImmutablePureComponent {
rewriteMentions={settings.get('rewrite_mentions')}
/>
<StatusReactionsBar
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
emojiMap={this.props.emojiMap}
/>
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
<StatusActionBar
status={status}

@ -0,0 +1,177 @@
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 EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import Icon from './icon';
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 StatusReactionsBar extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
handleEmojiPick = data => {
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 (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
statusId={this.props.statusId}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
);
}
}
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 (
<button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
title={`:${shortCode}:`}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</button>
);
}
}
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 (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else if (emojiMap.get(emoji)) {
const filename = (autoPlayGif || hovered)
? emojiMap.getIn([emoji, 'url'])
: emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
return null;
}
}
}

@ -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,
@ -42,6 +43,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' },
@ -81,6 +86,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,
@ -164,6 +170,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'),

@ -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,
@ -35,6 +40,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) {
@ -61,6 +97,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:

@ -70,6 +70,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
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
@ -262,6 +263,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

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

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

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

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

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

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_11_04_133904) do
ActiveRecord::Schema.define(version: 2022_11_24_114030) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -781,6 +781,16 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do
t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id"
end
create_table "reactions", force: :cascade do |t|
t.string "emoji"
t.bigint "status_id", null: false
t.bigint "account_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_reactions_on_account_id"
t.index ["status_id"], name: "index_reactions_on_status_id"
end
create_table "relays", force: :cascade do |t|
t.string "inbox_url", default: "", null: false
t.string "follow_activity_id"
@ -897,6 +907,19 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) 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
@ -1198,6 +1221,8 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do
add_foreign_key "polls", "accounts", on_delete: :cascade
add_foreign_key "polls", "statuses", on_delete: :cascade
add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
add_foreign_key "reactions", "accounts"
add_foreign_key "reactions", "statuses"
add_foreign_key "report_notes", "accounts", on_delete: :cascade
add_foreign_key "report_notes", "reports", on_delete: :cascade
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
@ -1211,6 +1236,9 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) 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"
add_foreign_key "status_reactions", "custom_emojis"
add_foreign_key "status_reactions", "statuses"
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

@ -0,0 +1,6 @@
Fabricator(:status_reaction) do
account nil
status nil
name "MyString"
custom_emoji nil
end

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe StatusReaction, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
Loading…
Cancel
Save