Compare commits

...

23 commits

Author SHA1 Message Date
Essem
11900689bf
Use a consumer for identity 2024-04-10 17:44:26 -05:00
Essem
9b54b27bf7
Fix reaction picker dropdown appearance 2024-04-10 17:44:25 -05:00
Essem
40ae5aed66
Merge fixes 2024-04-10 17:44:25 -05:00
Essem
13c9fa62fa
Hydrate reactions on streaming API 2024-04-10 17:44:25 -05:00
Essem
ba37843ec2
Purge status reactions on account delete 2024-04-10 17:44:25 -05:00
Essem
b07f5f89d4
Fix rubocop lint issue 2024-04-10 17:44:25 -05:00
Essem
aae6e1b1fd
Refactor status reactions query
This was done to announcement reactions in 1b0cb3b54d. Might as well do it here too.
2024-04-10 17:44:25 -05:00
Essem
a12b2ad57a
Simplify reactions API controller 2024-04-10 17:44:25 -05:00
Essem
d54affc107
Update reaction emails
Reaction icon made by t3rminus@calamity.world
2024-04-10 17:44:24 -05:00
Essem
0d68ecf75d
Revert variant selector normalization
Probably worth tackling later, but for now it's not worth worrying about; some other implementations (e.g. Misskey's) look to have the same behavior anyways.
2024-04-10 17:44:24 -05:00
Essem
4311fff076
Move reaction normalization to API controller 2024-04-10 17:44:24 -05:00
Essem
a2ab3f541c
Quick fixes 2024-04-10 17:44:24 -05:00
Essem
11bebd28a2
Make name of like content parser function more general 2024-04-10 17:44:24 -05:00
Essem
09b64d761a
Normalize emojis with variant selectors 2024-04-10 17:44:24 -05:00
Essem
0838432237
Check for content attribute in Misskey likes 2024-04-10 17:44:24 -05:00
Essem
a27b838741
Fix rubocop complaint 2024-04-10 17:44:24 -05:00
Essem
4d832522b9
Add reaction notification column settings
This was in a previous PR. Not quite sure how it didn't carry over.
2024-04-10 17:44:23 -05:00
Essem
03ea7618ad
More cleanup 2024-04-10 17:44:23 -05:00
Essem
22fc82dfee
Linting fixes 2024-04-10 17:44:23 -05:00
Essem
938175d5e8
Refactor react services 2024-04-10 17:44:23 -05:00
Essem
28ecb2a4be
Fix reblog reactions 2024-04-10 17:44:23 -05:00
Essem
14c0e46ef4
Add notification emails for reactions 2024-04-10 17:44:23 -05:00
Essem
227a8d71b3
Add support for emoji reactions
Squashed, modified, and rebased from glitch-soc/mastodon#2221.

Co-authored-by: fef <owo@fef.moe>
Co-authored-by: Jeremy Kescher <jeremy@kescher.at>
Co-authored-by: neatchee <neatchee@gmail.com>
Co-authored-by: Ivan Rodriguez <104603218+IRod22@users.noreply.github.com>
Co-authored-by: Plastikmensch <plastikmensch@users.noreply.github.com>
2024-04-10 17:44:23 -05:00
68 changed files with 1131 additions and 19 deletions

View file

@ -274,6 +274,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_REACTIONS=1
# Maximum image and video/audio upload sizes
# Units are in bytes
# 1048576 bytes equals 1 megabyte

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user!
def create
ReactService.new.call(current_account, @status, params[:id])
render json: @status, serializer: REST::StatusSerializer
end
def destroy
UnreactWorker.perform_async(current_account.id, @status.id, params[:id])
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reactions_map: { @status.id => false })
rescue Mastodon::NotPermittedError
not_found
end
end

View file

@ -51,6 +51,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const REACTION_UPDATE = 'REACTION_UPDATE';
export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST';
export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS';
export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL';
export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST';
export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS';
export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL';
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@ -516,3 +526,75 @@ export function unpinFail(status, error) {
skipLoading: true,
};
}
export const addReaction = (statusId, name, url) => (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(addReactionRequest(statusId, name, url));
}
// encodeURIComponent is required for the Keycap Number Sign emoji, see:
// <https://github.com/glitch-soc/mastodon/pull/1980#issuecomment-1345538932>
api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => {
dispatch(addReactionSuccess(statusId, name));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(statusId, name, err));
}
});
};
export const addReactionRequest = (statusId, name, url) => ({
type: REACTION_ADD_REQUEST,
id: statusId,
name,
url,
});
export const addReactionSuccess = (statusId, name) => ({
type: REACTION_ADD_SUCCESS,
id: statusId,
name,
});
export const addReactionFail = (statusId, name, error) => ({
type: REACTION_ADD_FAIL,
id: statusId,
name,
error,
});
export const removeReaction = (statusId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(statusId, name));
api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => {
dispatch(removeReactionSuccess(statusId, name));
}).catch(err => {
dispatch(removeReactionFail(statusId, name, err));
});
};
export const removeReactionRequest = (statusId, name) => ({
type: REACTION_REMOVE_REQUEST,
id: statusId,
name,
});
export const removeReactionSuccess = (statusId, name) => ({
type: REACTION_REMOVE_SUCCESS,
id: statusId,
name,
});
export const removeReactionFail = (statusId, name) => ({
type: REACTION_REMOVE_FAIL,
id: statusId,
name,
});

View file

@ -174,6 +174,7 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',

View file

@ -20,8 +20,9 @@ import Card from '../features/status/components/card';
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { IdentityConsumer } from '../features/ui/util/identity_consumer';
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia } from '../initial_state';
import { displayMedia, visibleReactions } from '../initial_state';
import AttachmentList from './attachment_list';
import { CollapseButton } from './collapse_button';
@ -31,6 +32,7 @@ import StatusContent from './status_content';
import StatusHeader from './status_header';
import StatusIcons from './status_icons';
import StatusPrepend from './status_prepend';
import StatusReactions from './status_reactions';
const domParser = new DOMParser();
@ -91,6 +93,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,
@ -758,6 +762,7 @@ class Status extends ImmutablePureComponent {
if (this.props.prepend && account) {
const notifKind = {
favourite: 'favourited',
reaction: 'reacted',
reblog: 'boosted',
reblogged_by: 'boosted',
status: 'posted',
@ -841,6 +846,19 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
<IdentityConsumer>
{identity => (
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
numVisible={visibleReactions}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={identity.signedIn}
/>
)}
</IdentityConsumer>
{(!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar']))) && (
<StatusActionBar
status={status}

View file

@ -8,6 +8,7 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -26,7 +27,8 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { me } from '../initial_state';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { me, maxReactions } from '../initial_state';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
@ -48,6 +50,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: 'Favorite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
@ -76,6 +79,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReactionAdd: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -133,6 +137,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
};
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@ -321,6 +329,8 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
);
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
return (
<div className='status__action-bar'>
<IconButton
@ -334,6 +344,7 @@ class StatusActionBar extends ImmutablePureComponent {
/>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
{filterButton}

View file

@ -9,6 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
@ -69,6 +70,14 @@ export default class StatusPrepend extends PureComponent {
values={{ name : link }}
/>
);
case 'reaction':
return (
<FormattedMessage
id='notification.reaction'
defaultMessage='{name} reacted to your status'
values={{ name: link }}
/>
);
case 'reblog':
return (
<FormattedMessage
@ -124,6 +133,10 @@ export default class StatusPrepend extends PureComponent {
iconId = 'star';
iconComponent = StarIcon;
break;
case 'reaction':
iconId = 'mood';
iconComponent = MoodIcon;
break;
case 'featured':
iconId = 'thumb-tack';
iconComponent = PushPinIcon;

View file

@ -0,0 +1,175 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
import { autoPlayGif, reduceMotion } from '../initial_state';
import { assetHost } from '../utils/config';
import { AnimatedNumber } from './animated_number';
export default class StatusReactions extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
numVisible: PropTypes.number,
addReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.isRequired,
removeReaction: PropTypes.func.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'));
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 (
<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}
canReact={this.props.canReact}
/>
))}
</div>
)}
</TransitionMotion>
);
}
}
class Reaction extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
canReact: PropTypes.bool.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;
return (
<button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
disabled={!this.props.canReact}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji
hovered={this.state.hovered}
emoji={reaction.get('name')}
url={reaction.get('url')}
staticUrl={reaction.get('static_url')}
/>
</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,
hovered: PropTypes.bool.isRequired,
url: PropTypes.string,
staticUrl: PropTypes.string,
};
render() {
const { emoji, hovered, url, staticUrl } = 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 {
const filename = (autoPlayGif || hovered) ? url : staticUrl;
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
}
}
}

View file

@ -21,6 +21,8 @@ import {
unbookmark,
pin,
unpin,
addReaction,
removeReaction,
} from 'flavours/glitch/actions/interactions';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { openModal } from 'flavours/glitch/actions/modal';
@ -173,6 +175,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onReactionAdd (statusId, name, url) {
dispatch(addReaction(statusId, name, url));
},
onReactionRemove (statusId, name) {
dispatch(removeReaction(statusId, name));
},
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',

View file

@ -327,6 +327,9 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
title: PropTypes.string,
icon: PropTypes.node,
disabled: PropTypes.bool,
};
state = {
@ -361,7 +364,7 @@ class EmojiPickerDropdown extends 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 {
@ -389,19 +392,18 @@ class EmojiPickerDropdown extends PureComponent {
};
render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, title, icon, disabled } = this.props;
const { active, loading, placement } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
<IconButton
title={title}
title={title || intl.formatMessage(messages.emoji)}
aria-expanded={active}
active={active}
iconComponent={MoodIcon}
disabled={disabled}
iconComponent={icon || MoodIcon}
onClick={this.onToggle}
inverted
/>
<Overlay show={active} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>

View file

@ -161,6 +161,17 @@ export default class ColumnSettings extends PureComponent {
</div>
</section>
<section role='group' aria-labelledby='notifications-reaction'>
<h3 id='notifications-reaction'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></h3>
<div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-mention'>
<h3 id='notifications-mention'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></h3>

View file

@ -5,6 +5,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
@ -14,6 +15,7 @@ import { Icon } from 'flavours/glitch/components/icon';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
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' },
@ -81,6 +83,13 @@ class FilterBar extends PureComponent {
>
<Icon id='star' icon={StarIcon} />
</button>
<button
className={selectedFilter === 'reaction' ? 'active' : ''}
onClick={this.onClick('reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='mood' icon={MoodIcon} />
</button>
<button
className={selectedFilter === 'reblog' ? 'active' : ''}
onClick={this.onClick('reblog')}

View file

@ -205,6 +205,31 @@ class Notification extends ImmutablePureComponent {
);
}
renderReaction (notification) {
return (
<StatusContainer
containerId={notification.get('id')}
hidden={!!this.props.hidden}
id={notification.get('status')}
account={notification.get('account')}
prepend='reaction'
muted
withDismiss
notification={notification}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
onMention={this.props.onMention}
contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
onUnmount={this.props.onUnmount}
unread={this.props.unread}
/>
);
}
renderReblog (notification) {
return (
<StatusContainer
@ -413,6 +438,8 @@ class Notification extends ImmutablePureComponent {
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification);
case 'reaction':
return this.renderReaction(notification);
case 'reblog':
return this.renderReblog(notification);
case 'status':

View file

@ -8,6 +8,7 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import AddReactionIcon from '@/material-icons/400-24px/add_reaction.svg?react';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -26,7 +27,8 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { IconButton } from '../../../components/icon_button';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { me } from '../../../initial_state';
import { me, maxReactions } from '../../../initial_state';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -40,6 +42,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: 'Favorite' },
react: { id: 'status.react', defaultMessage: 'React' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@ -69,6 +72,7 @@ class ActionBar extends PureComponent {
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReactionAdd: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@ -96,6 +100,10 @@ class ActionBar extends PureComponent {
this.props.onFavourite(this.props.status, e);
};
handleEmojiPick = data => {
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl);
};
handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e);
};
@ -231,6 +239,8 @@ class ActionBar extends PureComponent {
replyIconComponent = ReplyAllIcon;
}
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle, reblogIconComponent;
@ -254,6 +264,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={replyIcon} iconComponent={replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'>

View file

@ -21,6 +21,7 @@ import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import StatusReactions from '../../../components/status_reactions';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
@ -29,6 +30,10 @@ import Card from './card';
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
settings: ImmutablePropTypes.map.isRequired,
@ -47,6 +52,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onReactionAdd: PropTypes.func.isRequired,
onReactionRemove: PropTypes.func.isRequired,
...WithRouterPropTypes,
};
@ -307,6 +314,14 @@ class DetailedStatus extends ImmutablePureComponent {
{...statusContentProps}
/>
<StatusReactions
statusId={status.get('id')}
reactions={status.get('reactions')}
addReaction={this.props.onReactionAdd}
removeReaction={this.props.onReactionRemove}
canReact={this.context.identity.signedIn}
/>
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>

View file

@ -40,6 +40,8 @@ import {
unreblog,
pin,
unpin,
addReaction,
removeReaction,
} from '../../actions/interactions';
import { changeLocalSetting } from '../../actions/local_settings';
import { openModal } from '../../actions/modal';
@ -308,6 +310,19 @@ class Status extends ImmutablePureComponent {
}
};
handleReactionAdd = (statusId, name, url) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
dispatch(addReaction(statusId, name, url));
}
};
handleReactionRemove = (statusId, name) => {
this.props.dispatch(removeReaction(statusId, name));
};
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@ -748,6 +763,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}
@ -762,6 +779,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReactionAdd={this.handleReactionAdd}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}

View file

@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
export class IdentityConsumer extends PureComponent {
static contextTypes = {
identity: PropTypes.object
};
static propTypes = {
children: PropTypes.func.isRequired
};
render() {
return this.props.children(this.context.identity);
}
}

View file

@ -24,6 +24,7 @@
* @property {boolean} limited_federation_mode
* @property {string} locale
* @property {string | null} mascot
* @property {number} max_reactions
* @property {string=} me
* @property {string=} moved_to_account_id
* @property {string=} owner
@ -44,6 +45,7 @@
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {number} visible_reactions
* @property {string} sso_redirect
* @property {string} status_page_url
* @property {boolean} system_emoji_font
@ -59,6 +61,7 @@
* @property {object} local_settings
* @property {number} max_feed_hashtags
* @property {number} poll_limits
* @property {number} max_reactions
*/
const element = document.getElementById('initial-state');
@ -103,6 +106,7 @@ export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const maxReactions = (initialState && initialState.max_reactions) || 1;
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const owner = getMeta('owner');
@ -121,6 +125,7 @@ export const trendsAsLanding = getMeta('trends_as_landing_page');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const visibleReactions = getMeta('visible_reactions');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');

View file

@ -59,12 +59,15 @@
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.misc": "Misc",
"notification.markForDeletion": "Mark for deletion",
"notification.reaction": "{name} reacted to your post",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_apply": "Clear\nselected",
"notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_none": "Select\nnone",
"notification_purge.start": "Enter notification cleaning mode",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
"notifications.column_settings.reaction": "Reactions:",
"notifications.filter.reactions": "Reactions",
"notifications.marked_clear": "Clear selected notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"settings.always_show_spoilers_field": "Always enable the Content Warning field",
@ -155,6 +158,7 @@
"status.in_reply_to": "This toot is a reply",
"status.is_poll": "This toot is a poll",
"status.local_only": "Only visible from your instance",
"status.react": "React",
"status.uncollapse": "Uncollapse",
"suggestions.dismiss": "Dismiss suggestion"
}

View file

@ -35,6 +35,7 @@ const initialState = ImmutableMap({
follow: false,
follow_request: false,
favourite: false,
reaction: false,
reblog: false,
mention: false,
poll: false,
@ -57,6 +58,7 @@ const initialState = ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reaction: true,
reblog: true,
mention: true,
poll: true,
@ -70,6 +72,7 @@ const initialState = ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reaction: true,
reblog: true,
mention: true,
poll: true,

View file

@ -15,6 +15,11 @@ import {
BOOKMARK_FAIL,
UNBOOKMARK_REQUEST,
UNBOOKMARK_FAIL,
REACTION_UPDATE,
REACTION_ADD_FAIL,
REACTION_REMOVE_FAIL,
REACTION_ADD_REQUEST,
REACTION_REMOVE_REQUEST,
} from '../actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -42,6 +47,43 @@ 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));
// 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)
.update('url', old => old ? old : url)
.update('static_url', old => old ? old : url),
);
const removeReaction = (state, id, name) => updateReaction(
state,
id,
name,
x => x.set('me', false).update('count', n => n - 1),
);
const statusTranslateSuccess = (state, id, translation) => {
return state.withMutations(map => {
map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id))));
@ -95,6 +137,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 REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case REACTION_ADD_REQUEST:
case REACTION_REMOVE_FAIL:
return addReaction(state, action.id, action.name, action.url);
case REACTION_REMOVE_REQUEST:
case REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name);
case UNREBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], false);
case UNREBLOG_FAIL:

View file

@ -1469,7 +1469,8 @@ body > [data-popper-placement] {
border-bottom: 0;
.status__content,
.status__action-bar {
.status__action-bar,
.reactions-bar {
margin-inline-start: $thread-margin;
width: calc(100% - ($thread-margin));
}
@ -1598,6 +1599,10 @@ body > [data-popper-placement] {
width: 24px;
height: 24px;
}
.reactions-bar--empty {
display: none;
}
}
.status__relative-time {
@ -1812,6 +1817,14 @@ body > [data-popper-placement] {
&-spacer {
flex-grow: 1;
}
& > .emoji-picker-dropdown {
height: 24px;
> .emoji-button {
padding: 0;
}
}
}
.detailed-status__action-bar-dropdown {
@ -4962,6 +4975,10 @@ a.status-card {
text-align: center;
}
.detailed-status__button .emoji-button {
padding: 0;
}
.column-settings {
display: flex;
flex-direction: column;
@ -9073,6 +9090,8 @@ noscript {
}
&--empty {
margin-top: 0;
.emoji-button {
padding: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M800-680v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q43 0 83 8.5t77 24.5v167h80v80h142q9 29 13.5 58.5T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

After

Width:  |  Height:  |  Size: 622 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-480Zm0 400q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q43 0 83 8.5t77 24.5v90q-35-20-75.5-31.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160q133 0 226.5-93.5T800-480q0-32-6.5-62T776-600h86q9 29 13.5 58.5T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm320-600v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Z"/></svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

After

Width:  |  Height:  |  Size: 559 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 320q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Z"/></svg>

After

Width:  |  Height:  |  Size: 656 B

View file

@ -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'
@ -176,4 +178,32 @@ class ActivityPub::Activity
Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_actor] && "via #{@options[:relayed_through_actor].uri}"}")
nil
end
# Ensure emoji declared in the activity's tags are
# present in the database and downloaded to the local cache.
# Required by EmojiReact and Like for emoji reactions.
def process_emoji_tags(name, tags)
tag = as_array(tags).find { |item| item['type'] == 'Emoji' }
return if tag.nil?
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? || !name.eql?(custom_emoji_parser.shortcode)
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
return emoji unless emoji.nil? ||
custom_emoji_parser.image_remote_url != emoji.image_remote_url ||
(custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
begin
emoji ||= CustomEmoji.new(domain: @account.domain,
shortcode: custom_emoji_parser.shortcode,
uri: custom_emoji_parser.uri)
emoji.image_remote_url = custom_emoji_parser.image_remote_url
emoji.save
rescue Seahorse::Client::NetworkingError => e
Rails.logger.warn "Error fetching emoji: #{e}"
return
end
emoji
end
end

View file

@ -0,0 +1,26 @@
# 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'])
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @json['tag'])
return if custom_emoji.nil?
end
return if @account.reacted?(original_status, name, custom_emoji)
reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
rescue ActiveRecord::RecordInvalid
nil
end
end

View file

@ -3,12 +3,39 @@
class ActivityPub::Activity::Like < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id'])
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
return if maybe_process_embedded_reaction
return if @account.favourited?(original_status)
favourite = original_status.favourites.create!(account: @account)
LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite')
Trends.statuses.register(original_status)
end
# Some servers deliver reactions as likes with the emoji in content
# Versions of Misskey before 12.1.0 specify emojis in _misskey_reaction instead, so we check both
# See https://misskey-hub.net/ns.html#misskey-reaction for details
def maybe_process_embedded_reaction
original_status = status_from_uri(object_uri)
name = @json['content'] || @json['_misskey_reaction']
return false if name.nil?
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @json['tag'])
return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like
end
return true if @account.reacted?(original_status, name, custom_emoji)
reaction = original_status.status_reactions.create!(account: @account, name: name, custom_emoji: custom_emoji)
LocalNotificationWorker.perform_async(original_status.account_id, reaction.id, 'StatusReaction', 'reaction')
true
# account tried to react with disabled custom emoji. Returning true to discard activity.
rescue ActiveRecord::RecordInvalid
true
end
end

View file

@ -11,6 +11,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
undo_follow
when 'Like'
undo_like
when 'EmojiReact'
undo_emoji_react
when 'Block'
undo_block
when nil
@ -108,6 +110,31 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
if @account.favourited?(status)
favourite = status.favourites.where(account: @account).first
favourite&.destroy
elsif @object['content'].present? || @object['_misskey_reaction'].present?
undo_emoji_react
else
delete_later!(object_uri)
end
end
def undo_emoji_react
name = @object['content'] || @object['_misskey_reaction']
return if name.nil?
status = status_from_uri(target_uri)
return if status.nil? || !status.account.local?
if /^:.*:$/.match?(name)
name.delete! ':'
custom_emoji = process_emoji_tags(name, @object['tag'])
return if custom_emoji.nil?
end
if @account.reacted?(status, name, custom_emoji)
reaction = status.status_reactions.where(account: @account, name: name).first
reaction&.destroy
else
delete_later!(object_uri)
end

View file

@ -32,6 +32,7 @@ class StatusCacheHydrator
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.id)
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.id) if @status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, @status)
payload[:reactions] = serialized_reactions(account_id)
if payload[:poll]
payload[:poll][:voted] = @status.account_id == account_id
@ -57,6 +58,7 @@ class StatusCacheHydrator
payload[:reblog][:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.reblog_of_id)
payload[:reblog][:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.reblog_of_id) if @status.reblog.account_id == account_id
payload[:reblog][:filtered] = payload[:filtered]
payload[:reblog][:reactions] = serialized_reactions(account_id)
if payload[:reblog][:poll]
if @status.reblog.account_id == account_id
@ -71,6 +73,7 @@ class StatusCacheHydrator
payload[:favourited] = payload[:reblog][:favourited]
payload[:reblogged] = payload[:reblog][:reblogged]
payload[:reactions] = payload[:reblog][:reactions]
end
end
@ -87,6 +90,16 @@ class StatusCacheHydrator
).as_json
end
def serialized_reactions(account_id)
reactions = @status.reactions(account_id)
ActiveModelSerializers::SerializableResource.new(
reactions,
each_serializer: REST::ReactionSerializer,
scope: account_id, # terrible
scope_name: :current_user
).as_json
end
def payload_application
@status.application.present? ? serialized_status_application_json : nil
end

View file

@ -6,8 +6,8 @@ class NotificationMailer < ApplicationMailer
:routing
before_action :process_params
before_action :set_status, only: [:mention, :favourite, :reblog]
before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
before_action :set_status, only: [:mention, :favourite, :reaction, :reblog]
before_action :set_account, only: [:follow, :favourite, :reaction, :reblog, :follow_request]
after_action :set_list_headers!
default to: -> { email_address_with_name(@user.email, @me.username) }
@ -40,6 +40,15 @@ class NotificationMailer < ApplicationMailer
end
end
def reaction
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail subject: default_i18n_subject(name: @account.acct)
end
end
def reblog
return unless @user.functional? && @status.present?

View file

@ -13,6 +13,7 @@ module Account::Associations
# Timelines
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :status_reactions, inverse_of: :account, dependent: :destroy
has_many :bookmarks, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account

View file

@ -235,6 +235,10 @@ module Account::Interactions
status.proper.favourites.exists?(account: self)
end
def reacted?(status, name, custom_emoji = nil)
status.proper.status_reactions.exists?(account: self, name: name, custom_emoji: custom_emoji)
end
def bookmarked?(status)
status.proper.bookmarks.exists?(account: self)
end

View file

@ -119,6 +119,10 @@ module User::HasSettings
settings['hide_followers_count']
end
def setting_visible_reactions
integer_cast_setting('visible_reactions', 0)
end
def allows_report_emails?
settings['notification_emails.report']
end
@ -162,4 +166,14 @@ module User::HasSettings
def hide_all_media?
settings['web.display_media'] == 'hide_all'
end
def integer_cast_setting(key, min = nil, max = nil)
i = ActiveModel::Type::Integer.new.cast(settings[key])
# the cast above doesn't return a number if passed the string "e"
i = 0 unless i.is_a? Numeric
return min if !min.nil? && i < min
return max if !max.nil? && i > max
i
end
end

View file

@ -26,6 +26,7 @@ class Notification < ApplicationRecord
'Follow' => :follow,
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'StatusReaction' => :reaction,
'Poll' => :poll,
}.freeze
@ -48,6 +49,9 @@ class Notification < ApplicationRecord
favourite: {
filterable: true,
}.freeze,
reaction: {
filterable: true,
}.freeze,
poll: {
filterable: false,
}.freeze,
@ -72,6 +76,7 @@ class Notification < ApplicationRecord
reblog: [status: :reblog],
mention: [mention: :status],
favourite: [favourite: :status],
reaction: [status_reaction: :status],
poll: [poll: :status],
update: :status,
'admin.report': [report: :target_account],
@ -87,6 +92,7 @@ class Notification < ApplicationRecord
belongs_to :follow, inverse_of: :notification
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :status_reaction, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
belongs_to :account_relationship_severance_event, inverse_of: false
@ -108,6 +114,8 @@ class Notification < ApplicationRecord
status&.reblog
when :favourite
favourite&.status
when :reaction
status_reaction&.status
when :mention
mention&.status
when :poll
@ -169,6 +177,8 @@ class Notification < ApplicationRecord
end
end
alias reaction status_reaction
after_initialize :set_from_account
before_validation :set_from_account
@ -180,7 +190,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
when 'Status', 'Follow', 'Favourite', 'StatusReaction', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id

View file

@ -74,6 +74,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
has_many :status_reactions, inverse_of: :status, dependent: :destroy
# The `dependent` option is enabled by the initial `mentions` association declaration
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status # rubocop:disable Rails/HasManyOrHasOneDependent
@ -281,6 +282,16 @@ class Status < ApplicationRecord
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
end
def reactions(account_id = nil)
grouped_ordered_status_reactions.select(
[:name, :custom_emoji_id, 'COUNT(*) as count'].tap do |values|
values << value_for_reaction_me_column(account_id)
end
).to_a.tap do |records|
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji).call
end
end
def ordered_media_attachments
if ordered_media_attachment_ids.nil?
# NOTE: sort Ruby-side to avoid hitting the database when the status is
@ -468,6 +479,35 @@ class Status < ApplicationRecord
private
def grouped_ordered_status_reactions
status_reactions
.group(:status_id, :name, :custom_emoji_id)
.order(
Arel.sql('MIN(created_at)').asc
)
end
def value_for_reaction_me_column(account_id)
if account_id.nil?
'FALSE AS me'
else
<<~SQL.squish
EXISTS(
SELECT 1
FROM status_reactions inner_reactions
WHERE inner_reactions.account_id = #{account_id}
AND inner_reactions.status_id = status_reactions.status_id
AND inner_reactions.name = status_reactions.name
AND (
inner_reactions.custom_emoji_id = status_reactions.custom_emoji_id
OR inner_reactions.custom_emoji_id IS NULL
AND status_reactions.custom_emoji_id IS NULL
)
) AS me
SQL
end
end
def update_status_stat!(attrs)
return if marked_for_destruction? || destroyed?

View file

@ -0,0 +1,37 @@
# 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
has_one :notification, as: :activity, dependent: :destroy
validates :name, presence: true
validates_with StatusReactionValidator
before_validation do
self.status = status.reblog if status&.reblog?
end
before_validation :set_custom_emoji
private
# Sets custom_emoji to nil when disabled
def set_custom_emoji
self.custom_emoji = CustomEmoji.find_by(disabled: false, shortcode: name, domain: custom_emoji.domain) if name.present? && custom_emoji.present?
end
end

View file

@ -18,6 +18,7 @@ class UserSettings
setting :default_privacy, default: nil, in: %w(public unlisted private)
setting :default_content_type, default: 'text/plain'
setting :hide_followers_count, default: false
setting :visible_reactions, default: 6
setting_inverse_alias :indexable, :noindex
setting_inverse_alias :show_followers_count, :hide_followers_count
@ -43,6 +44,7 @@ class UserSettings
setting :follow, default: true
setting :reblog, default: false
setting :favourite, default: false
setting :reaction, default: false
setting :mention, default: true
setting :follow_request, default: true
setting :report, default: true

View file

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

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :content
attribute :virtual_object, key: :object
attribute :custom_emoji, key: :tag, 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
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

View file

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

View file

@ -6,13 +6,17 @@ class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
:media_attachments, :settings,
:max_feed_hashtags, :poll_limits,
:languages
:languages, :max_reactions
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
has_one :role, serializer: REST::RoleSerializer
def max_reactions
StatusReactionValidator::LIMIT
end
def max_feed_hashtags
TagFeed::LIMIT_PER_MODE
end
@ -29,8 +33,8 @@ class InitialStateSerializer < ActiveModel::Serializer
def meta
store = default_meta_store
if object.current_account
store[:me] = object.current_account.id.to_s
if object_account
store[:me] = object_account.id.to_s
store[:boost_modal] = object_account_user.setting_boost_modal
store[:favourite_modal] = object_account_user.setting_favourite_modal
store[:delete_modal] = object_account_user.setting_delete_modal
@ -45,6 +49,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_content_type] = object_account_user.setting_default_content_type
store[:system_emoji_font] = object_account_user.setting_system_emoji_font
store[:show_trends] = Setting.trends && object_account_user.setting_trends
store[:visible_reactions] = object_account_user.setting_visible_reactions
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media
@ -128,6 +133,10 @@ class InitialStateSerializer < ActiveModel::Serializer
}
end
def object_account
object.current_account
end
def object_account_user
object.current_account.user
end

View file

@ -82,6 +82,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
translation: {
enabled: TranslationService.configured?,
},
reactions: {
max_reactions: StatusReactionValidator::LIMIT,
},
}
end

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?

View file

@ -21,6 +21,14 @@ class REST::ReactionSerializer < ActiveModel::Serializer
object.custom_emoji.present?
end
def name
if extern?
[object.name, '@', object.custom_emoji.domain].join
else
object.name
end
end
def url
full_asset_url(object.custom_emoji.image.url)
end
@ -28,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

View file

@ -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
@ -156,6 +157,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def reactions
object.reactions(current_user&.account&.id)
end
private
def relationships

View file

@ -97,6 +97,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
},
reactions: {
max_reactions: StatusReactionValidator::LIMIT,
},
}
end

View file

@ -151,6 +151,7 @@ class DeleteAccountService < BaseService
purge_polls!
purge_generated_notifications!
purge_favourites!
purge_status_reactions!
purge_bookmarks!
purge_feeds!
purge_other_associations!
@ -198,6 +199,15 @@ class DeleteAccountService < BaseService
end
end
def purge_status_reactions!
@account.status_reactions.in_batches do |status_reactions|
ids = status_reactions.pluck(:status_id)
Chewy.strategy.current.update(StatusesIndex, ids) if Chewy.enabled?
Rails.cache.delete_multi(ids.map { |id| "statuses/#{id}" })
status_reactions.delete_all
end
end
def purge_bookmarks!
@account.bookmarks.in_batches do |bookmarks|
Chewy.strategy.current.update(StatusesIndex, bookmarks.pluck(:status_id)) if Chewy.enabled?

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class ReactService < BaseService
include Authorization
include Payloadable
def call(account, status, emoji)
authorize_with account, status, :react?
name, domain = emoji.split('@')
return unless domain.nil? || status.local?
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?
reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji)
Trends.statuses.register(status)
create_notification(reaction)
increment_statistics
reaction
end
private
def create_notification(reaction)
status = reaction.status
if status.account.local?
LocalNotificationWorker.perform_async(status.account_id, reaction.id, 'StatusReaction', 'reaction')
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(reaction), reaction.account_id, status.account.inbox_url)
end
end
def increment_statistics
ActivityTracker.increment('activity:interactions')
end
def build_json(reaction)
Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer))
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class UnreactService < BaseService
include Payloadable
def call(account, status, emoji)
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 if reaction.nil?
reaction.destroy!
create_notification(reaction) if !status.account.local? && status.account.activitypub?
reaction
end
private
def create_notification(reaction)
status = reaction.status
ActivityPub::DeliveryWorker.perform_async(build_json(reaction), reaction.account_id, status.account.inbox_url)
end
def build_json(reaction)
Oj.dump(serialize_payload(reaction, ActivityPub::UndoEmojiReactionSerializer))
end
end

View file

@ -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 = [1, (ENV['MAX_REACTIONS'] || 1).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 reaction.account.local? && 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.exists?(status: reaction.status, account: reaction.account, name: reaction.name, custom_emoji: reaction.custom_emoji)
end
def limit_reached?(reaction)
reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT
end
end

View file

@ -0,0 +1,13 @@
= content_for :heading do
= render 'application/mailer/heading', heading_title: t('notification_mailer.reaction.title'), heading_subtitle: t('notification_mailer.reaction.body', name: @account.pretty_acct), heading_image_url: frontend_asset_url('images/mailer-new/heading/reaction.png')
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-body-padding-td
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-inner-card-td
= render 'status', status: @status, time_zone: @me.user_time_zone
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-padding-top-24
= render 'application/mailer/button', text: t('application_mailer.view_status'), url: web_url("@#{@status.account.pretty_acct}/#{@status.id}")

View file

@ -0,0 +1,5 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('notification_mailer.reaction.body', name: @account.pretty_acct) %>
<%= render 'status', status: @status %>

View file

@ -51,6 +51,9 @@
= ff.input :'web.use_system_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_font_ui')
= ff.input :'web.use_system_emoji_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_emoji_font'), glitch_only: true
.fields-group.fields-row__column.fields-row__column-6
= ff.input :visible_reactions, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_visible_reactions'), input_html: { type: 'number', min: '0', data: { default: '6' } }, hint: false
%h4= t 'appearance.discovery'
.fields-group

View file

@ -17,6 +17,7 @@
= ff.input :'notification_emails.follow_request', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.follow_request')
= ff.input :'notification_emails.reblog', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.reblog')
= ff.input :'notification_emails.favourite', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.favourite')
= ff.input :'notification_emails.reaction', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.reaction')
= ff.input :'notification_emails.mention', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.mention')
.fields-group

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class UnreactWorker
include Sidekiq::Worker
def perform(account_id, status_id, emoji)
UnreactService.new.call(Account.find(account_id), Status.find(status_id), emoji)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -38,5 +38,10 @@ en:
title: User verification
generic:
use_this: Use this
notification_mailer:
reaction:
body: "%{name} reacted to your post:"
subject: "%{name} reacted to your post"
title: New reaction
settings:
flavours: Flavours

View file

@ -20,7 +20,9 @@ en:
setting_show_followers_count: Show 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:
reaction: Someone reacted to your post
trending_link: New trending link requires review
trending_status: New trending post requires review
trending_tag: New trending tag requires review

View file

@ -16,6 +16,11 @@ namespace :api, format: false do
resource :favourite, only: :create
post :unfavourite, to: 'favourites#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: %r{[^/]+} }
post '/unreact/:id', to: 'reactions#destroy', constraints: { id: %r{[^/]+} }
resource :bookmark, only: :create
post :unbookmark, to: 'bookmarks#destroy'

View file

@ -22,6 +22,7 @@ defaults: &defaults
trends_as_landing_page: true
trendable_by_default: false
trending_status_cw: true
visible_reactions: 6
hide_followers_count: false
reserved_usernames:
- admin

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateStatusReactions < ActiveRecord::Migration[6.1]
def change
create_table :status_reactions do |t|
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: { on_delete: :cascade }
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

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class MoveEmojiReactionSettings < ActiveRecord::Migration[6.1]
class User < ApplicationRecord; end
MAPPING = {
setting_visible_reactions: 'visible_reactions',
}.freeze
class LegacySetting < ApplicationRecord
self.table_name = 'settings'
def var
self[:var]&.to_sym
end
def value
YAML.safe_load(self[:value], permitted_classes: [ActiveSupport::HashWithIndifferentAccess]) if self[:value].present?
end
end
def up
User.find_in_batches do |users|
previous_settings_for_batch = LegacySetting.where(thing_type: 'User', thing_id: users.map(&:id)).group_by(&:thing_id)
users.each do |user|
previous_settings = previous_settings_for_batch[user.id]&.index_by(&:var) || {}
user_settings = Oj.load(user.settings || '{}')
user_settings.delete('theme')
MAPPING.each do |legacy_key, new_key|
value = previous_settings[legacy_key]&.value
next if value.blank?
if value.is_a?(Hash)
value.each do |nested_key, nested_value|
user_settings[MAPPING[legacy_key][nested_key.to_sym]] = nested_value
end
else
user_settings[new_key] = value
end
end
user.update_column('settings', Oj.dump(user_settings))
end
end
end
end

View file

@ -1034,6 +1034,18 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_22_161611) 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 ["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
@ -1366,6 +1378,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_22_161611) 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

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
Fabricator(:status_reaction) do
account
status
name '👍'
custom_emoji
end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
require 'rails_helper'