mirror of
https://github.com/glitch-soc/mastodon
synced 2025-04-24 17:44:50 +00:00
Merge 572bde2483
into e866641a2b
This commit is contained in:
commit
f2a24a2592
70 changed files with 1152 additions and 24 deletions
|
@ -284,6 +284,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
|
||||
|
|
19
app/controllers/api/v1/statuses/reactions_controller.rb
Normal file
19
app/controllers/api/v1/statuses/reactions_controller.rb
Normal 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
|
|
@ -47,6 +47,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 * from "./interactions_typed";
|
||||
|
||||
export function favourite(status) {
|
||||
|
@ -494,3 +504,75 @@ export function toggleFavourite(statusId, skipModal = false) {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
@ -71,7 +71,7 @@ function dispatchAssociatedRecords(
|
|||
}
|
||||
|
||||
function selectNotificationGroupedTypes(state: RootState) {
|
||||
const types: NotificationType[] = ['favourite', 'reblog'];
|
||||
const types: NotificationType[] = ['favourite', 'reblog', 'reaction'];
|
||||
|
||||
if (selectSettingsNotificationsGroupFollows(state)) types.push('follow');
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ export const allNotificationTypes = [
|
|||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reaction',
|
||||
'reblog',
|
||||
'mention',
|
||||
'poll',
|
||||
|
@ -25,6 +26,7 @@ export const allNotificationTypes = [
|
|||
|
||||
export type NotificationWithStatusType =
|
||||
| 'favourite'
|
||||
| 'reaction'
|
||||
| 'reblog'
|
||||
| 'status'
|
||||
| 'mention'
|
||||
|
|
|
@ -11,6 +11,7 @@ import { HotKeys } from 'react-hotkeys';
|
|||
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
|
@ -20,7 +21,7 @@ import Card from '../features/status/components/card';
|
|||
import Bundle from '../features/ui/components/bundle';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
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 { Avatar } from './avatar';
|
||||
|
@ -33,6 +34,7 @@ import StatusActionBar from './status_action_bar';
|
|||
import StatusContent from './status_content';
|
||||
import StatusIcons from './status_icons';
|
||||
import StatusPrepend from './status_prepend';
|
||||
import StatusReactions from './status_reactions';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
|
@ -78,6 +80,7 @@ class Status extends ImmutablePureComponent {
|
|||
static contextType = SensitiveMediaContext;
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
containerId: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
status: ImmutablePropTypes.map,
|
||||
|
@ -93,6 +96,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,
|
||||
|
@ -363,7 +368,7 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.onClick();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const { history } = this.props;
|
||||
const status = this.props.status;
|
||||
|
||||
|
@ -453,6 +458,7 @@ class Status extends ImmutablePureComponent {
|
|||
onOpenMedia,
|
||||
notification,
|
||||
history,
|
||||
identity,
|
||||
...other
|
||||
} = this.props;
|
||||
let attachments = null;
|
||||
|
@ -646,6 +652,7 @@ class Status extends ImmutablePureComponent {
|
|||
if (this.props.prepend && account) {
|
||||
const notifKind = {
|
||||
favourite: 'favourited',
|
||||
reaction: 'reacted',
|
||||
reblog: 'boosted',
|
||||
reblogged_by: 'boosted',
|
||||
status: 'posted',
|
||||
|
@ -734,6 +741,15 @@ class Status extends ImmutablePureComponent {
|
|||
{/* This is a glitch-soc addition to have a placeholder */}
|
||||
{!expanded && <MentionsPlaceholder status={status} />}
|
||||
|
||||
<StatusReactions
|
||||
statusId={status.get('id')}
|
||||
reactions={status.get('reactions')}
|
||||
numVisible={visibleReactions}
|
||||
addReaction={this.props.onReactionAdd}
|
||||
removeReaction={this.props.onReactionRemove}
|
||||
canReact={this.props.identity.signedIn}
|
||||
/>
|
||||
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
account={status.get('account')}
|
||||
|
@ -749,4 +765,4 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default withOptionalRouter(injectIntl(Status));
|
||||
export default withOptionalRouter(injectIntl((withIdentity(Status))));
|
||||
|
|
|
@ -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';
|
||||
|
@ -27,7 +28,8 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
|||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
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';
|
||||
|
@ -49,6 +51,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' },
|
||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
|
@ -75,6 +78,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,
|
||||
|
@ -132,6 +136,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.props.identity;
|
||||
|
||||
|
@ -322,6 +330,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
</div>
|
||||
);
|
||||
|
||||
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||
|
||||
|
@ -344,6 +353,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
|
|
|
@ -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';
|
||||
|
@ -65,6 +66,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
|
||||
|
@ -120,6 +129,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;
|
||||
|
|
181
app/javascript/flavours/glitch/components/status_reactions.jsx
Normal file
181
app/javascript/flavours/glitch/components/status_reactions.jsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent, useMemo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
|
||||
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';
|
||||
|
||||
const StatusReactions = ({
|
||||
statusId,
|
||||
reactions,
|
||||
numVisible,
|
||||
addReaction,
|
||||
canReact,
|
||||
removeReaction,
|
||||
}) => {
|
||||
const visibleReactions = useMemo(() => {
|
||||
let visible = reactions
|
||||
.filter(x => x.get('count') > 0)
|
||||
.sort((a, b) => b.get('count') - a.get('count'));
|
||||
|
||||
if (numVisible >= 0) {
|
||||
visible = visible.filter((_, i) => i < numVisible);
|
||||
}
|
||||
|
||||
return visible.toArray();
|
||||
}, [numVisible, reactions]);
|
||||
|
||||
const transitions = useTransition(visibleReactions, {
|
||||
from: {
|
||||
scale: 0,
|
||||
},
|
||||
initial: {
|
||||
scale: 1,
|
||||
},
|
||||
enter: {
|
||||
scale: 1,
|
||||
},
|
||||
leave: {
|
||||
scale: 0,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
keys: visibleReactions.map(x => x.get('name')),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.length === 0 })}>
|
||||
{transitions(({ scale }, reaction) => (
|
||||
<Reaction
|
||||
key={reaction.get('name')}
|
||||
statusId={statusId}
|
||||
reaction={reaction}
|
||||
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||
addReaction={addReaction}
|
||||
removeReaction={removeReaction}
|
||||
canReact={canReact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
StatusReactions.propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
numVisible: PropTypes.number,
|
||||
addReaction: PropTypes.func,
|
||||
canReact: PropTypes.bool.isRequired,
|
||||
removeReaction: PropTypes.func,
|
||||
};
|
||||
|
||||
class Reaction extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string,
|
||||
reaction: ImmutablePropTypes.map.isRequired,
|
||||
addReaction: PropTypes.func,
|
||||
removeReaction: PropTypes.func,
|
||||
canReact: PropTypes.bool.isRequired,
|
||||
style: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovered: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { reaction, statusId, addReaction, removeReaction, canReact } = this.props;
|
||||
if (!canReact) return;
|
||||
|
||||
if (reaction.get('me') && removeReaction) {
|
||||
removeReaction(statusId, reaction.get('name'));
|
||||
} else if (addReaction) {
|
||||
addReaction(statusId, reaction.get('name'));
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseEnter = () => this.setState({ hovered: true });
|
||||
|
||||
handleMouseLeave = () => this.setState({ hovered: false });
|
||||
|
||||
render() {
|
||||
const { reaction } = this.props;
|
||||
|
||||
return (
|
||||
<animated.button
|
||||
type='button'
|
||||
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
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>
|
||||
</animated.button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Emoji extends 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default StatusReactions;
|
|
@ -16,6 +16,8 @@ import {
|
|||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
|
@ -107,6 +109,14 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onReactionAdd (statusId, name, url) {
|
||||
dispatch(addReaction(statusId, name, url));
|
||||
},
|
||||
|
||||
onReactionRemove (statusId, name) {
|
||||
dispatch(removeReaction(statusId, name));
|
||||
},
|
||||
|
||||
onEmbed (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
|
|
|
@ -336,6 +336,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 = {
|
||||
|
@ -370,7 +373,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 {
|
||||
|
@ -398,19 +401,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 }}>
|
||||
|
|
|
@ -71,7 +71,7 @@ class ColumnSettings extends PureComponent {
|
|||
|
||||
<section role='group' aria-labelledby='notifications-filter-bar'>
|
||||
<h3 id='notifications-filter-bar'><FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /></h3>
|
||||
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
|
||||
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
|
||||
|
@ -125,6 +125,17 @@ 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>
|
||||
|
||||
|
|
|
@ -204,6 +204,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
|
||||
|
@ -433,6 +458,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':
|
||||
|
|
|
@ -16,6 +16,7 @@ import { NotificationFollowRequest } from './notification_follow_request';
|
|||
import { NotificationMention } from './notification_mention';
|
||||
import { NotificationModerationWarning } from './notification_moderation_warning';
|
||||
import { NotificationPoll } from './notification_poll';
|
||||
import { NotificationReaction } from './notification_reaction';
|
||||
import { NotificationReblog } from './notification_reblog';
|
||||
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
||||
import { NotificationStatus } from './notification_status';
|
||||
|
@ -79,6 +80,14 @@ export const NotificationGroup: React.FC<{
|
|||
/>
|
||||
);
|
||||
break;
|
||||
case 'reaction':
|
||||
content = (
|
||||
<NotificationReaction
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'severed_relationships':
|
||||
content = (
|
||||
<NotificationSeveredRelationships
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
|
||||
import type { NotificationGroupReaction } from 'flavours/glitch/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationGroupWithStatus } from './notification_group_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (displayedName, total) => {
|
||||
if (total === 1)
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.reaction'
|
||||
defaultMessage='{name} reacted to your status'
|
||||
values={{ name: displayedName }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.reaction.name_and_others'
|
||||
defaultMessage='{name} and {count, plural, one {# other} other {# others}} reacted to your post'
|
||||
values={{
|
||||
name: displayedName,
|
||||
count: total - 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationReaction: React.FC<{
|
||||
notification: NotificationGroupReaction;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
return (
|
||||
<NotificationGroupWithStatus
|
||||
type='reaction'
|
||||
icon={MoodIcon}
|
||||
iconId='react'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
statusId={notification.statusId}
|
||||
timestamp={notification.latest_page_notification_at}
|
||||
count={notification.notifications_count}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } 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';
|
||||
|
@ -24,6 +25,10 @@ const tooltips = defineMessages({
|
|||
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' },
|
||||
|
@ -97,6 +102,14 @@ export const FilterBar: React.FC = () => {
|
|||
>
|
||||
<Icon id='star' icon={StarIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='reaction'
|
||||
key='reaction'
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='react' icon={MoodIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='reblog'
|
||||
|
|
|
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
|||
|
||||
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';
|
||||
|
@ -25,7 +26,8 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
|||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
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' },
|
||||
|
@ -39,6 +41,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' },
|
||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
|
@ -66,6 +69,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,
|
||||
|
@ -92,6 +96,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);
|
||||
};
|
||||
|
@ -227,6 +235,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;
|
||||
|
@ -253,6 +263,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={favouriteTitle} 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={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
|
|
|
@ -25,12 +25,15 @@ import { Permalink } from 'flavours/glitch/components/permalink';
|
|||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import { Video } from 'flavours/glitch/features/video';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
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 { visibleReactions } from '../../../initial_state';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
|
||||
|
@ -56,6 +59,8 @@ export const DetailedStatus: React.FC<{
|
|||
pictureInPicture: any;
|
||||
onToggleHidden?: (status: any) => void;
|
||||
onToggleMediaVisibility?: () => void;
|
||||
onReactionAdd?: (status: any, name: string, url: string) => void;
|
||||
onReactionRemove?: (status: any, name: string) => void;
|
||||
expanded: boolean;
|
||||
}> = ({
|
||||
status,
|
||||
|
@ -71,12 +76,15 @@ export const DetailedStatus: React.FC<{
|
|||
pictureInPicture,
|
||||
onToggleMediaVisibility,
|
||||
onToggleHidden,
|
||||
onReactionAdd,
|
||||
onReactionRemove,
|
||||
expanded,
|
||||
}) => {
|
||||
const properStatus = status?.get('reblog') ?? status;
|
||||
const [height, setHeight] = useState(0);
|
||||
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
const { signedIn } = useIdentity();
|
||||
|
||||
const rewriteMentions = useAppSelector(
|
||||
(state) => state.local_settings.get('rewrite_mentions', false) as boolean,
|
||||
|
@ -401,6 +409,16 @@ export const DetailedStatus: React.FC<{
|
|||
{/* This is a glitch-soc addition to have a placeholder */}
|
||||
{!expanded && <MentionsPlaceholder status={status} />}
|
||||
|
||||
{visibleReactions && visibleReactions > 0 && (
|
||||
<StatusReactions
|
||||
statusId={status.get('id')}
|
||||
reactions={status.get('reactions')}
|
||||
addReaction={onReactionAdd}
|
||||
removeReaction={onReactionRemove}
|
||||
canReact={signedIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<div className='detailed-status__meta__line'>
|
||||
<a
|
||||
|
|
|
@ -39,6 +39,8 @@ import {
|
|||
toggleReblog,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from '../../actions/interactions';
|
||||
import { openModal } from '../../actions/modal';
|
||||
import { initMuteModal } from '../../actions/mutes';
|
||||
|
@ -279,6 +281,19 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleReactionAdd = (statusId, name, url) => {
|
||||
const { dispatch, identity } = this.props;
|
||||
const { signedIn } = 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));
|
||||
|
@ -710,6 +725,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}
|
||||
|
@ -724,6 +741,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}
|
||||
|
|
|
@ -26,6 +26,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
|
||||
|
@ -46,6 +47,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} terms_of_service_enabled
|
||||
|
@ -72,6 +74,7 @@
|
|||
* @property {object} local_settings
|
||||
* @property {number} max_feed_hashtags
|
||||
* @property {number} poll_limits
|
||||
* @property {number} max_reactions
|
||||
*/
|
||||
|
||||
const element = document.getElementById('initial-state');
|
||||
|
@ -118,6 +121,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');
|
||||
|
@ -136,6 +140,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 criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
|
|
|
@ -48,7 +48,11 @@
|
|||
"navigation_bar.app_settings": "App settings",
|
||||
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"navigation_bar.misc": "Misc",
|
||||
"notification.reaction": "{name} reacted to your post",
|
||||
"notification.reaction.name_and_others": "{name} and {count, plural, one {# other} other {# others}} reacted to your post",
|
||||
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
|
||||
"notifications.column_settings.reaction": "Reactions:",
|
||||
"notifications.filter.reactions": "Reactions",
|
||||
"settings.always_show_spoilers_field": "Always enable the Content Warning field",
|
||||
"settings.close": "Close",
|
||||
"settings.compose_box_opts": "Compose box",
|
||||
|
@ -121,5 +125,6 @@
|
|||
"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.show_filter_reason": "Show anyway"
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ interface BaseNotification<Type extends NotificationType>
|
|||
|
||||
export type NotificationGroupFavourite =
|
||||
BaseNotificationWithStatus<'favourite'>;
|
||||
export type NotificationGroupReaction = BaseNotificationWithStatus<'reaction'>;
|
||||
export type NotificationGroupReblog = BaseNotificationWithStatus<'reblog'>;
|
||||
export type NotificationGroupStatus = BaseNotificationWithStatus<'status'>;
|
||||
export type NotificationGroupMention = BaseNotificationWithStatus<'mention'>;
|
||||
|
@ -84,6 +85,7 @@ export interface NotificationGroupAdminReport
|
|||
|
||||
export type NotificationGroup =
|
||||
| NotificationGroupFavourite
|
||||
| NotificationGroupReaction
|
||||
| NotificationGroupReblog
|
||||
| NotificationGroupStatus
|
||||
| NotificationGroupMention
|
||||
|
@ -134,6 +136,7 @@ export function createNotificationGroupFromJSON(
|
|||
|
||||
switch (group.type) {
|
||||
case 'favourite':
|
||||
case 'reaction':
|
||||
case 'reblog':
|
||||
case 'status':
|
||||
case 'mention':
|
||||
|
@ -206,6 +209,7 @@ export function createNotificationGroupFromNotificationJSON(
|
|||
|
||||
switch (notification.type) {
|
||||
case 'favourite':
|
||||
case 'reaction':
|
||||
case 'reblog':
|
||||
case 'status':
|
||||
case 'mention':
|
||||
|
|
|
@ -35,6 +35,7 @@ const initialState = ImmutableMap({
|
|||
follow: false,
|
||||
follow_request: false,
|
||||
favourite: false,
|
||||
reaction: false,
|
||||
reblog: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
|
@ -58,6 +59,7 @@ const initialState = ImmutableMap({
|
|||
follow: true,
|
||||
follow_request: false,
|
||||
favourite: true,
|
||||
reaction: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
|
@ -71,6 +73,7 @@ const initialState = ImmutableMap({
|
|||
follow: true,
|
||||
follow_request: false,
|
||||
favourite: true,
|
||||
reaction: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
|
|
|
@ -13,6 +13,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 {
|
||||
reblog,
|
||||
|
@ -43,6 +48,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))));
|
||||
|
@ -93,6 +135,14 @@ export default function statuses(state = initialState, action) {
|
|||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
||||
case UNBOOKMARK_FAIL:
|
||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
|
||||
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 STATUS_MUTE_SUCCESS:
|
||||
return state.setIn([action.id, 'muted'], true);
|
||||
case STATUS_UNMUTE_SUCCESS:
|
||||
|
|
|
@ -1557,6 +1557,7 @@ body > [data-popper-placement] {
|
|||
|
||||
.status__content,
|
||||
.status__action-bar,
|
||||
.reactions-bar,
|
||||
.media-gallery,
|
||||
.video-player,
|
||||
.audio-player,
|
||||
|
@ -1795,6 +1796,14 @@ body > [data-popper-placement] {
|
|||
&-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
& > .emoji-picker-dropdown {
|
||||
height: 24px;
|
||||
|
||||
> .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__action-bar-dropdown {
|
||||
|
@ -4752,6 +4761,10 @@ a.status-card {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.detailed-status__button .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.column-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -8776,6 +8789,8 @@ noscript {
|
|||
}
|
||||
|
||||
&--empty {
|
||||
margin-top: 0;
|
||||
|
||||
.emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -10591,6 +10606,10 @@ noscript {
|
|||
color: $gold-star;
|
||||
}
|
||||
|
||||
&--reaction &__icon {
|
||||
color: $blurple-300;
|
||||
}
|
||||
|
||||
&--reblog &__icon {
|
||||
color: $valid-value-color;
|
||||
}
|
||||
|
@ -10807,6 +10826,7 @@ noscript {
|
|||
|
||||
.status__content,
|
||||
.status__action-bar,
|
||||
.reactions-bar,
|
||||
.media-gallery,
|
||||
.video-player,
|
||||
.audio-player,
|
||||
|
|
BIN
app/javascript/images/mailer-new/heading/reaction.png
Normal file
BIN
app/javascript/images/mailer-new/heading/reaction.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
BIN
app/javascript/images/mailer/icon_add.png
Normal file
BIN
app/javascript/images/mailer/icon_add.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
|
@ -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 |
1
app/javascript/material-icons/400-24px/add_reaction.svg
Normal file
1
app/javascript/material-icons/400-24px/add_reaction.svg
Normal 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 |
|
@ -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'
|
||||
|
@ -171,4 +173,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
|
||||
|
|
28
app/lib/activitypub/activity/emoji_react.rb
Normal file
28
app/lib/activitypub/activity/emoji_react.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::EmojiReact < ActivityPub::Activity
|
||||
CUSTOM_EMOJI_REGEX = /^:[^:]+:$/
|
||||
|
||||
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 CUSTOM_EMOJI_REGEX.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
|
|
@ -1,14 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||
CUSTOM_EMOJI_REGEX = /^:[^:]+:$/
|
||||
|
||||
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 CUSTOM_EMOJI_REGEX.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
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||
CUSTOM_EMOJI_REGEX = /^:[^:]+:$/
|
||||
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Announce'
|
||||
|
@ -11,6 +13,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 +112,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 CUSTOM_EMOJI_REGEX.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
|
||||
|
|
|
@ -71,6 +71,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, status)
|
||||
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id) if payload[:quote]
|
||||
end
|
||||
|
||||
|
@ -103,6 +104,16 @@ class StatusCacheHydrator
|
|||
).as_json
|
||||
end
|
||||
|
||||
def serialized_reactions(account_id, status)
|
||||
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
|
||||
|
|
|
@ -6,11 +6,11 @@ class NotificationMailer < ApplicationMailer
|
|||
:routing
|
||||
|
||||
before_action :process_params
|
||||
with_options only: %i(mention favourite reblog) do
|
||||
with_options only: %i(mention favourite reaction reblog) do
|
||||
before_action :set_status
|
||||
after_action :thread_by_conversation!
|
||||
end
|
||||
before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
|
||||
before_action :set_account, only: [:follow, :favourite, :reaction, :reblog, :follow_request]
|
||||
after_action :set_list_headers!
|
||||
|
||||
before_deliver :verify_functional_user
|
||||
|
@ -41,6 +41,14 @@ class NotificationMailer < ApplicationMailer
|
|||
end
|
||||
end
|
||||
|
||||
def reaction
|
||||
return if @status.blank?
|
||||
|
||||
locale_for_account(@me) do
|
||||
mail subject: default_i18n_subject(name: @account.acct)
|
||||
end
|
||||
end
|
||||
|
||||
def reblog
|
||||
return if @status.blank?
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ module Account::Associations
|
|||
has_many :reports
|
||||
has_many :scheduled_statuses
|
||||
has_many :status_pins
|
||||
has_many :status_reactions
|
||||
has_many :statuses
|
||||
|
||||
has_one :deletion_request, class_name: 'AccountDeletionRequest'
|
||||
|
|
|
@ -238,6 +238,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
|
||||
|
|
|
@ -4,7 +4,7 @@ module Notification::Groups
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
# `set_group_key!` needs to be updated if this list changes
|
||||
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog follow admin.sign_up).freeze
|
||||
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reaction reblog follow admin.sign_up).freeze
|
||||
MAXIMUM_GROUP_SPAN_HOURS = 12
|
||||
|
||||
included do
|
||||
|
@ -15,7 +15,7 @@ module Notification::Groups
|
|||
return if filtered? || GROUPABLE_NOTIFICATION_TYPES.exclude?(type)
|
||||
|
||||
type_prefix = case type
|
||||
when :favourite, :reblog
|
||||
when :favourite, :reaction, :reblog
|
||||
[type, target_status&.id].join('-')
|
||||
when :follow, :'admin.sign_up'
|
||||
type
|
||||
|
|
|
@ -127,6 +127,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
|
||||
|
@ -170,4 +174,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
|
||||
|
|
|
@ -29,6 +29,7 @@ class Notification < ApplicationRecord
|
|||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'StatusReaction' => :reaction,
|
||||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
|
@ -52,6 +53,9 @@ class Notification < ApplicationRecord
|
|||
favourite: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
reaction: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
poll: {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
|
@ -82,6 +86,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],
|
||||
|
@ -97,6 +102,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
|
||||
|
@ -120,6 +126,8 @@ class Notification < ApplicationRecord
|
|||
status&.reblog
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :reaction
|
||||
status_reaction&.status
|
||||
when :mention
|
||||
mention&.status
|
||||
when :poll
|
||||
|
@ -181,6 +189,8 @@ class Notification < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
alias reaction status_reaction
|
||||
|
||||
after_initialize :set_from_account
|
||||
before_validation :set_from_account
|
||||
|
||||
|
@ -192,7 +202,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
|
||||
|
|
|
@ -77,6 +77,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
|
||||
|
@ -277,6 +278,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
|
||||
|
@ -450,6 +461,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?
|
||||
|
||||
|
|
37
app/models/status_reaction.rb
Normal file
37
app/models/status_reaction.rb
Normal 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
|
|
@ -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
|
||||
|
@ -46,6 +47,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
|
||||
|
|
|
@ -28,6 +28,10 @@ class StatusPolicy < ApplicationPolicy
|
|||
show? && !blocking_author?
|
||||
end
|
||||
|
||||
def react?
|
||||
show? && !blocking_author?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
owned?
|
||||
end
|
||||
|
|
39
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal file
39
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal 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
|
|
@ -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
|
|
@ -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 # rubocop:disable Metrics/AbcSize
|
||||
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
|
||||
|
@ -47,6 +51,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
|
||||
|
@ -127,10 +132,15 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
trends_as_landing_page: Setting.trends_as_landing_page,
|
||||
trends_enabled: Setting.trends,
|
||||
version: instance_presenter.version,
|
||||
visible_reactions: Setting.visible_reactions,
|
||||
terms_of_service_enabled: TermsOfService.live.exists?,
|
||||
}
|
||||
end
|
||||
|
||||
def object_account
|
||||
object.current_account
|
||||
end
|
||||
|
||||
def object_account_user
|
||||
object.current_account.user
|
||||
end
|
||||
|
|
|
@ -100,6 +100,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
translation: {
|
||||
enabled: TranslationService.configured?,
|
||||
},
|
||||
|
||||
reactions: {
|
||||
max_reactions: StatusReactionValidator::LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ class REST::NotificationGroupSerializer < 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?
|
||||
|
|
|
@ -21,7 +21,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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -30,6 +30,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 :quote, key: :quote, serializer: REST::QuoteSerializer
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
|
@ -159,6 +160,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
|
||||
|
|
|
@ -89,6 +89,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
|||
min_expiration: PollExpirationValidator::MIN_EXPIRATION,
|
||||
max_expiration: PollExpirationValidator::MAX_EXPIRATION,
|
||||
},
|
||||
|
||||
reactions: {
|
||||
max_reactions: StatusReactionValidator::LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -150,6 +150,7 @@ class DeleteAccountService < BaseService
|
|||
purge_polls!
|
||||
purge_generated_notifications!
|
||||
purge_favourites!
|
||||
purge_status_reactions!
|
||||
purge_bookmarks!
|
||||
purge_feeds!
|
||||
purge_other_associations!
|
||||
|
@ -197,6 +198,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?
|
||||
|
|
46
app/services/react_service.rb
Normal file
46
app/services/react_service.rb
Normal 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
|
27
app/services/unreact_service.rb
Normal file
27
app/services/unreact_service.rb
Normal 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
|
28
app/validators/status_reaction_validator.rb
Normal file
28
app/validators/status_reaction_validator.rb
Normal 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
|
16
app/views/notification_mailer/reaction.html.haml
Normal file
16
app/views/notification_mailer/reaction.html.haml
Normal file
|
@ -0,0 +1,16 @@
|
|||
= content_for :heading do
|
||||
= render 'application/mailer/heading',
|
||||
image_url: frontend_asset_url('images/mailer-new/heading/reaction.png'),
|
||||
subtitle: t('notification_mailer.reaction.body', name: @status.account.pretty_acct),
|
||||
title: t('notification_mailer.reaction.title')
|
||||
%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}")
|
5
app/views/notification_mailer/reaction.text.erb
Normal file
5
app/views/notification_mailer/reaction.text.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
|
||||
|
||||
<%= raw t('notification_mailer.reaction.body', name: @status.account.pretty_acct) %>
|
||||
|
||||
<%= render 'status', status: @status %>
|
|
@ -54,6 +54,9 @@
|
|||
= ff.input :'web.use_system_emoji_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_emoji_font'), glitch_only: true
|
||||
= ff.input :'web.use_system_scrollbars', wrapper: :with_label, hint: I18n.t('simple_form.hints.defaults.setting_system_scrollbars_ui'), label: I18n.t('simple_form.labels.defaults.setting_system_scrollbars_ui')
|
||||
|
||||
.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
|
||||
|
|
|
@ -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
|
||||
|
|
11
app/workers/unreact_worker.rb
Normal file
11
app/workers/unreact_worker.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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:
|
||||
- abuse
|
||||
|
|
16
db/migrate/20221124114030_create_status_reactions.rb
Normal file
16
db/migrate/20221124114030_create_status_reactions.rb
Normal 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
|
15
db/schema.rb
15
db/schema.rb
|
@ -1040,6 +1040,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) 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
|
||||
|
@ -1393,6 +1405,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) 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
|
||||
|
|
8
spec/fabricators/status_reaction_fabricator.rb
Normal file
8
spec/fabricators/status_reaction_fabricator.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:status_reaction) do
|
||||
account
|
||||
status
|
||||
name '👍'
|
||||
custom_emoji
|
||||
end
|
3
spec/models/status_reaction_spec.rb
Normal file
3
spec/models/status_reaction_spec.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
Loading…
Add table
Reference in a new issue