Compare commits
137 commits
cc94cc86ae
...
20b4d3ccbf
Author | SHA1 | Date | |
---|---|---|---|
Erin Shepherd | 20b4d3ccbf | ||
Erin Shepherd | 0e9097e164 | ||
Erin Shepherd | 67687000be | ||
6a4be4e966 | |||
b91756fd4d | |||
1e27826472 | |||
74bad9a4cb | |||
0ac4ccfc3a | |||
88257fe15c | |||
5173613890 | |||
c1f4e493e9 | |||
ac31a8d48d | |||
9ec39f98af | |||
48b00c2bdc | |||
e27fb1b632 | |||
76a519f83e | |||
8304dc14a7 | |||
cbc7bc95ef | |||
0eec369211 | |||
1ad2c68912 | |||
615ecb3161 | |||
2ffa61db05 | |||
4516cb47ac | |||
102fbc25be | |||
48a5f5f250 | |||
29627a4c6c | |||
59f73df49d | |||
3431edd68b | |||
0f59ce3e56 | |||
3956154a16 | |||
d61c47edb0 | |||
97043dce21 | |||
3fd6173203 | |||
aa76853d51 | |||
5e8f805447 | |||
341c663d29 | |||
b7c8a2b7b7 | |||
6ff67a6775 | |||
20166444de | |||
7fc71af0cc | |||
63c03cf902 | |||
fff8112a5f | |||
aa6abec827 | |||
a88d98f7d7 | |||
26972e3947 | |||
79b741ea93 | |||
cafc95381c | |||
0f29c1fa8f | |||
d65c974741 | |||
0e5bb30222 | |||
64defa3eed | |||
cb75d43185 | |||
9958664f55 | |||
5df48a4d8a | |||
9410d00d7b | |||
Erin Shepherd | a5cbf6b217 | ||
Erin Shepherd | 555867397c | ||
Erin Shepherd | 897f82e4ff | ||
Erin Shepherd | be790423ca | ||
Erin Shepherd | 4d3e044a8e | ||
Erin Shepherd | 8b303dcd90 | ||
Erin Shepherd | a27c21d267 | ||
Erin Shepherd | d817af1d26 | ||
Erin Shepherd | dad4b28db9 | ||
Erin Shepherd | a80e6a84d8 | ||
e35c31114f | |||
303cd4038a | |||
c957eb758c | |||
1d43e6b9b0 | |||
74c0ec42f6 | |||
6e5fc00fff | |||
1cb9c9dcca | |||
66ade5c1fd | |||
6da2a0d0fb | |||
55ba8f9c92 | |||
bb93649f38 | |||
e6c9206f5c | |||
7e16a2286d | |||
0ea02e608c | |||
a688a0b880 | |||
e0607e36a9 | |||
8dcf7b224c | |||
ea82a96b47 | |||
90a4c158f7 | |||
5de3784c9b | |||
bdd3c4691d | |||
6aa7d7fb12 | |||
7187d6f9cf | |||
14561a05c8 | |||
e3f97f60a6 | |||
935788db14 | |||
029097a1a0 | |||
a47ecf6e69 | |||
f4dbfdb9c9 | |||
be0bf21f3b | |||
6d2ad83c02 | |||
f535ddc445 | |||
e247dd17ed | |||
fd885bec48 | |||
b82984f0b5 | |||
852e6ef195 | |||
266bf2543d | |||
758f9f6384 | |||
8398f7ad4e | |||
079b0d15c5 | |||
4577711201 | |||
092e42a567 | |||
cacabea938 | |||
5b30421f3b | |||
91fcd87069 | |||
a5c6f4f4b0 | |||
c3d4a644cf | |||
Erin Shepherd | 367d552640 | ||
Erin Shepherd | 8e1bb28ecd | ||
Erin Shepherd | 2e074199c8 | ||
Erin Shepherd | 6c72698d8f | ||
Erin Shepherd | 1adc924e1c | ||
Erin Shepherd | 673547cfa5 | ||
Erin Shepherd | 5d49c2c553 | ||
54c532cf45 | |||
e2971a743f | |||
010cfac43e | |||
ca8e8d389d | |||
1dd461349a | |||
390c517c6f | |||
80f9fdb8bc | |||
2afd297091 | |||
7e794b1a24 | |||
99691e9a5a | |||
2d3e8bf64a | |||
1e404025d9 | |||
28287ad4aa | |||
0ed8464092 | |||
b95609f86a | |||
35ba1f089e | |||
38887f24a8 | |||
61fb0a9137 |
|
@ -269,6 +269,9 @@ MAX_POLL_OPTIONS=5
|
|||
# Maximum allowed poll option characters
|
||||
MAX_POLL_OPTION_CHARS=100
|
||||
|
||||
# Maximum number of emoji reactions per toot and user (minimum 1)
|
||||
MAX_REACTIONS=1
|
||||
|
||||
# Maximum image and video/audio upload sizes
|
||||
# Units are in bytes
|
||||
# 1048576 bytes equals 1 megabyte
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -63,3 +63,6 @@ yarn-debug.log
|
|||
|
||||
# Ignore Docker option files
|
||||
docker-compose.override.yml
|
||||
|
||||
# Ignore nix build output
|
||||
/result
|
||||
|
|
21
README.md
21
README.md
|
@ -1,14 +1,11 @@
|
|||
# Mastodon Glitch Edition #
|
||||
# Mastodon, Queer.af+Glitch Edition #
|
||||
|
||||
> Now with automated deploys!
|
||||
We're a downstream of [glitch-soc](https://github.com/glitch-soc/mastodon/). You probably want that
|
||||
|
||||
[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci]
|
||||
[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate]
|
||||
|
||||
[circleci]: https://circleci.com/gh/glitch-soc/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon
|
||||
|
||||
So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it?
|
||||
|
||||
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
|
||||
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
|
||||
## Updating
|
||||
```bash
|
||||
git pull upstream
|
||||
bundix
|
||||
nix-prefetch-yarn
|
||||
# Update yarn hash in default.nix
|
||||
```
|
||||
|
|
25
app/controllers/api/v1/statuses/reactions_controller.rb
Normal file
25
app/controllers/api/v1/statuses/reactions_controller.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::ReactionsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
|
||||
before_action :require_user!
|
||||
before_action :set_status
|
||||
|
||||
def create
|
||||
ReactService.new.call(current_account, @status, params[:id])
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy
|
||||
UnreactService.new.call(current_account, @status, params[:id])
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
end
|
||||
end
|
|
@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController
|
|||
:setting_use_pending_items,
|
||||
:setting_trends,
|
||||
:setting_crop_images,
|
||||
:setting_visible_reactions,
|
||||
:setting_always_send_emails,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
|
|
|
@ -41,6 +41,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));
|
||||
|
@ -391,4 +401,77 @@ export function unpinFail(status, error) {
|
|||
status,
|
||||
error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
|
@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => {
|
|||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reaction',
|
||||
'reblog',
|
||||
'mention',
|
||||
'poll',
|
||||
|
|
|
@ -6,6 +6,7 @@ import StatusHeader from './status_header';
|
|||
import StatusIcons from './status_icons';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusReactions from './status_reactions';
|
||||
import AttachmentList from './attachment_list';
|
||||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
@ -16,7 +17,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications
|
|||
import classNames from 'classnames';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { displayMedia } from 'flavours/glitch/initial_state';
|
||||
import { displayMedia, visibleReactions } from 'flavours/glitch/initial_state';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
|
@ -61,6 +62,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
@ -75,6 +77,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,
|
||||
|
@ -729,6 +733,7 @@ class Status extends ImmutablePureComponent {
|
|||
if (this.props.prepend && account) {
|
||||
const notifKind = {
|
||||
favourite: 'favourited',
|
||||
reaction: 'reacted',
|
||||
reblog: 'boosted',
|
||||
reblogged_by: 'boosted',
|
||||
status: 'posted',
|
||||
|
@ -807,6 +812,15 @@ class Status extends ImmutablePureComponent {
|
|||
rewriteMentions={settings.get('rewrite_mentions')}
|
||||
/>
|
||||
|
||||
<StatusReactions
|
||||
statusId={status.get('id')}
|
||||
reactions={status.get('reactions')}
|
||||
numVisible={visibleReactions}
|
||||
addReaction={this.props.onReactionAdd}
|
||||
removeReaction={this.props.onReactionRemove}
|
||||
canReact={this.context.identity.signedIn}
|
||||
/>
|
||||
|
||||
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
|
|
|
@ -5,11 +5,13 @@ import IconButton from './icon_button';
|
|||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { me, maxReactions } from 'flavours/glitch/initial_state';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
|
||||
import classNames from 'classnames';
|
||||
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
|
||||
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -28,6 +30,7 @@ const messages = defineMessages({
|
|||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
|
@ -58,6 +61,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,
|
||||
|
@ -115,6 +119,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;
|
||||
|
||||
|
@ -196,10 +204,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onAddFilter(this.props.status);
|
||||
};
|
||||
|
||||
handleNoOp = () => {} // hack for reaction add button
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
|
||||
const { permissions } = this.context.identity;
|
||||
|
||||
const anonymousAccess = !me;
|
||||
const mutingConversation = status.get('muted');
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
@ -300,6 +309,17 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
|
||||
);
|
||||
|
||||
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const reactButton = (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
|
||||
title={intl.formatMessage(messages.react)}
|
||||
disabled={!canReact}
|
||||
icon='plus'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton
|
||||
|
@ -312,6 +332,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
/>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} 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' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
{
|
||||
permissions
|
||||
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
|
||||
: reactButton
|
||||
}
|
||||
{shareButton}
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||
|
||||
|
|
|
@ -56,6 +56,14 @@ export default class StatusPrepend extends React.PureComponent {
|
|||
values={{ name : link }}
|
||||
/>
|
||||
);
|
||||
case 'reaction':
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='notification.reaction'
|
||||
defaultMessage='{name} reacted to your status'
|
||||
values={{ name: link }}
|
||||
/>
|
||||
);
|
||||
case 'reblog':
|
||||
return (
|
||||
<FormattedMessage
|
||||
|
@ -110,6 +118,9 @@ export default class StatusPrepend extends React.PureComponent {
|
|||
case 'favourite':
|
||||
iconId = 'star';
|
||||
break;
|
||||
case 'reaction':
|
||||
iconId = 'plus';
|
||||
break;
|
||||
case 'featured':
|
||||
iconId = 'thumb-tack';
|
||||
break;
|
||||
|
|
170
app/javascript/flavours/glitch/components/status_reactions.js
Normal file
170
app/javascript/flavours/glitch/components/status_reactions.js
Normal file
|
@ -0,0 +1,170 @@
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { autoPlayGif, reduceMotion } from '../initial_state';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
|
||||
import AnimatedNumber from './animated_number';
|
||||
import { assetHost } from '../utils/config';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Status from 'flavours/glitch/components/status';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
|
@ -16,6 +15,8 @@ import {
|
|||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import {
|
||||
muteStatus,
|
||||
|
@ -163,6 +164,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('EMBED', {
|
||||
url: status.get('url'),
|
||||
|
|
|
@ -318,6 +318,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -351,7 +352,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
};
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
|
|
|
@ -116,6 +116,17 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-reaction'>
|
||||
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-mention'>
|
||||
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import Icon from 'flavours/glitch/components/icon';
|
|||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
|
||||
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
|
@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent {
|
|||
>
|
||||
<Icon id='star' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('reaction')}
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='plus' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
|
|
|
@ -157,6 +157,28 @@ export default class Notification extends ImmutablePureComponent {
|
|||
unread={this.props.unread}
|
||||
/>
|
||||
);
|
||||
case 'reaction':
|
||||
return (
|
||||
<StatusContainer
|
||||
containerId={notification.get('id')}
|
||||
hidden={hidden}
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
prepend='reaction'
|
||||
muted
|
||||
notification={notification}
|
||||
onMoveDown={onMoveDown}
|
||||
onMoveUp={onMoveUp}
|
||||
onMention={onMention}
|
||||
getScrollPosition={getScrollPosition}
|
||||
updateScrollBottom={updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
onUnmount={this.props.onUnmount}
|
||||
withDismiss
|
||||
unread={this.props.unread}
|
||||
/>
|
||||
);
|
||||
case 'reblog':
|
||||
return (
|
||||
<StatusContainer
|
||||
|
|
|
@ -4,10 +4,11 @@ import IconButton from 'flavours/glitch/components/icon_button';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { me, maxReactions } from 'flavours/glitch/initial_state';
|
||||
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
|
||||
import classNames from 'classnames';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -21,6 +22,7 @@ const messages = defineMessages({
|
|||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
|
||||
|
@ -52,6 +54,7 @@ class ActionBar extends React.PureComponent {
|
|||
onReply: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onReactionAdd: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
|
@ -78,6 +81,10 @@ class ActionBar extends React.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);
|
||||
};
|
||||
|
@ -138,6 +145,8 @@ class ActionBar extends React.PureComponent {
|
|||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
handleNoOp = () => {} // hack for reaction add button
|
||||
|
||||
render () {
|
||||
const { status, intl } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
|
@ -195,6 +204,17 @@ class ActionBar extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const reactButton = (
|
||||
<IconButton
|
||||
className='plus-icon'
|
||||
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
|
||||
title={intl.formatMessage(messages.react)}
|
||||
disabled={!canReact}
|
||||
icon='plus'
|
||||
/>
|
||||
);
|
||||
|
||||
const shareButton = ('share' in navigator) && publicStatus && (
|
||||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
|
||||
);
|
||||
|
@ -217,6 +237,13 @@ class ActionBar extends React.PureComponent {
|
|||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' 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' onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'>
|
||||
{
|
||||
signedIn
|
||||
? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
|
||||
: reactButton
|
||||
}
|
||||
</div>
|
||||
{shareButton}
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
|
|
|
@ -20,12 +20,14 @@ import Icon from 'flavours/glitch/components/icon';
|
|||
import AnimatedNumber from 'flavours/glitch/components/animated_number';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
||||
import StatusReactions from 'flavours/glitch/components/status_reactions';
|
||||
|
||||
export default @injectIntl
|
||||
class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
@ -46,6 +48,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
available: PropTypes.bool,
|
||||
}),
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
onReactionAdd: PropTypes.func.isRequired,
|
||||
onReactionRemove: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -322,6 +326,14 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
disabled
|
||||
/>
|
||||
|
||||
<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'>
|
||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
|
|
|
@ -30,6 +30,8 @@ import {
|
|||
unreblog,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import {
|
||||
replyCompose,
|
||||
|
@ -41,7 +43,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
|||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import { makeCustomEmojiMap, makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
|
@ -296,6 +298,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));
|
||||
|
@ -681,6 +696,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}
|
||||
|
@ -695,6 +712,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}
|
||||
|
|
|
@ -83,7 +83,7 @@ class LinkFooter extends React.PureComponent {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:
|
||||
<strong>Mastodon, queer.af edition</strong>:
|
||||
{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
|
||||
{DividingCircle}
|
||||
|
|
|
@ -61,6 +61,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
|
||||
|
@ -80,6 +81,7 @@
|
|||
* @property {boolean} use_blurhash
|
||||
* @property {boolean=} use_pending_items
|
||||
* @property {string} version
|
||||
* @property {number} visible_reactions
|
||||
* @property {boolean} translation_enabled
|
||||
* @property {object} local_settings
|
||||
*/
|
||||
|
@ -122,6 +124,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');
|
||||
|
@ -140,6 +143,7 @@ export const unfollowModal = getMeta('unfollow_modal');
|
|||
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 translationEnabled = getMeta('translation_enabled');
|
||||
export const languages = initialState?.languages;
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
|
|
12
app/javascript/flavours/glitch/locales/de.js
Normal file
12
app/javascript/flavours/glitch/locales/de.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import inherited from 'mastodon/locales/de.json';
|
||||
|
||||
const messages = {
|
||||
'notification.reaction': '{name} hat auf deinen Beitrag reagiert',
|
||||
'notifications.column_settings.reaction': 'Reaktionen:',
|
||||
|
||||
'tooltips.reactions': 'Reaktionen',
|
||||
|
||||
'status.react': 'Reagieren',
|
||||
};
|
||||
|
||||
export default Object.assign({}, inherited, messages);
|
|
@ -57,6 +57,7 @@
|
|||
"keyboard_shortcuts.bookmark": "zu Lesezeichen hinzufügen",
|
||||
"keyboard_shortcuts.secondary_toot": "Toot mit sekundärer Privatsphäreeinstellung absenden",
|
||||
"keyboard_shortcuts.toggle_collapse": "Toots ein-/ausklappen",
|
||||
"tooltips.reactions": "Reaktionen",
|
||||
"layout.auto": "Automatisch",
|
||||
"layout.desktop": "Desktop",
|
||||
"layout.hint.auto": "Automatisch das Layout anhand der Einstellung \"Erweitertes Webinterface verwenden\" und Bildschirmgröße auswählen.",
|
||||
|
@ -71,6 +72,8 @@
|
|||
"navigation_bar.keyboard_shortcuts": "Tastaturkürzel",
|
||||
"navigation_bar.misc": "Sonstiges",
|
||||
"notification.markForDeletion": "Zum Entfernen auswählen",
|
||||
"notification.reaction": "{name} hat auf deinen Beitrag reagiert",
|
||||
"notifications.column_settings.reaction": "Reaktionen:",
|
||||
"notification_purge.btn_all": "Alle\nauswählen",
|
||||
"notification_purge.btn_apply": "Ausgewählte\nentfernen",
|
||||
"notification_purge.btn_invert": "Auswahl\numkehren",
|
||||
|
@ -124,6 +127,7 @@
|
|||
"settings.deprecated_setting": "Diese Einstellung wird nun von Mastodons {settings_page_link} gesteuert",
|
||||
"settings.enable_collapsed": "Eingeklappte Toots aktivieren",
|
||||
"settings.enable_collapsed_hint": "Eingeklappte Posts haben einen Teil ihres Inhalts verborgen, um weniger Platz am Bildschirm einzunehmen. Das passiert unabhängig von der Inhaltswarnfunktion",
|
||||
"settings.enter_amount_prompt": "Gib eine Zahl ein",
|
||||
"settings.enable_content_warnings_auto_unfold": "Inhaltswarnungen automatisch ausklappen",
|
||||
"settings.general": "Allgemein",
|
||||
"settings.hicolor_privacy_icons": "Eingefärbte Privatsphäre-Symbole",
|
||||
|
@ -137,6 +141,7 @@
|
|||
"settings.layout_opts": "Layout-Optionen",
|
||||
"settings.media": "Medien",
|
||||
"settings.media_fullwidth": "Medienvorschau in voller Breite",
|
||||
"settings.num_visible_reactions": "Anzahl sichtbarer Reaktionen",
|
||||
"settings.media_letterbox": "Mediengröße anpassen",
|
||||
"settings.media_letterbox_hint": "Medien runterskalieren und einpassen um die Bildbehälter zu füllen anstatt zu strecken und zuzuschneiden",
|
||||
"settings.media_reveal_behind_cw": "Empfindliche Medien hinter Inhaltswarnungen standardmäßig anzeigen",
|
||||
|
@ -179,6 +184,7 @@
|
|||
"settings.wide_view": "Breite Ansicht (nur für den Desktop-Modus)",
|
||||
"settings.wide_view_hint": "Verbreitert Spalten, um den verfügbaren Platz besser zu füllen.",
|
||||
"status.collapse": "Einklappen",
|
||||
"status.react": "Reagieren",
|
||||
"status.has_audio": "Hat angehängte Audiodateien",
|
||||
"status.has_pictures": "Hat angehängte Bilder",
|
||||
"status.has_preview_card": "Hat eine Vorschaukarte",
|
||||
|
|
197
app/javascript/flavours/glitch/locales/en.js
Normal file
197
app/javascript/flavours/glitch/locales/en.js
Normal file
|
@ -0,0 +1,197 @@
|
|||
import inherited from 'mastodon/locales/en.json';
|
||||
|
||||
const messages = {
|
||||
'getting_started.open_source_notice': 'Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.',
|
||||
'layout.auto': 'Auto',
|
||||
'layout.current_is': 'Your current layout is:',
|
||||
'layout.desktop': 'Desktop',
|
||||
'layout.single': 'Mobile',
|
||||
'layout.hint.auto': 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.',
|
||||
'layout.hint.desktop': 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.',
|
||||
'layout.hint.single': 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.',
|
||||
'navigation_bar.app_settings': 'App settings',
|
||||
'navigation_bar.misc': 'Misc',
|
||||
'navigation_bar.keyboard_shortcuts': 'Keyboard shortcuts',
|
||||
'navigation_bar.info': 'Extended information',
|
||||
'navigation_bar.featured_users': 'Featured users',
|
||||
'getting_started.onboarding': 'Show me around',
|
||||
'onboarding.next': 'Next',
|
||||
'onboarding.done': 'Done',
|
||||
'onboarding.skip': 'Skip',
|
||||
'onboarding.page_one.federation': '{domain} is an \'instance\' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.',
|
||||
'onboarding.page_one.welcome': 'Welcome to {domain}!',
|
||||
'onboarding.page_one.handle': 'You are on {domain}, so your full handle is {handle}',
|
||||
'onboarding.page_two.compose': 'Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.',
|
||||
'onboarding.page_three.search': 'Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.',
|
||||
'onboarding.page_three.profile': 'Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.',
|
||||
'onboarding.page_four.home': 'The home timeline shows posts from people you follow.',
|
||||
'onboarding.page_four.notifications': 'The notifications column shows when someone interacts with you.',
|
||||
'onboarding.page_five.public_timelines': 'The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.',
|
||||
'onboarding.page_six.admin': 'Your instance\'s admin is {admin}.',
|
||||
'onboarding.page_six.read_guidelines': 'Please read {domain}\'s {guidelines}!',
|
||||
'onboarding.page_six.guidelines': 'community guidelines',
|
||||
'onboarding.page_six.almost_done': 'Almost done...',
|
||||
'onboarding.page_six.github': '{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.',
|
||||
'onboarding.page_six.apps_available': 'There are {apps} available for iOS, Android and other platforms.',
|
||||
'onboarding.page_six.various_app': 'mobile apps',
|
||||
'onboarding.page_six.appetoot': 'Bon Appetoot!',
|
||||
'settings.auto_collapse': 'Automatic collapsing',
|
||||
'settings.auto_collapse_all': 'Everything',
|
||||
'settings.auto_collapse_lengthy': 'Lengthy toots',
|
||||
'settings.auto_collapse_media': 'Toots with media',
|
||||
'settings.auto_collapse_notifications': 'Notifications',
|
||||
'settings.auto_collapse_reblogs': 'Boosts',
|
||||
'settings.auto_collapse_replies': 'Replies',
|
||||
'settings.show_action_bar': 'Show action buttons in collapsed toots',
|
||||
'settings.close': 'Close',
|
||||
'settings.collapsed_statuses': 'Collapsed toots',
|
||||
'settings.enable_collapsed': 'Enable collapsed toots',
|
||||
'settings.enable_collapsed_hint': 'Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature',
|
||||
'settings.general': 'General',
|
||||
'settings.compose_box_opts': 'Compose box',
|
||||
'settings.side_arm': 'Secondary toot button:',
|
||||
'settings.side_arm.none': 'None',
|
||||
'settings.side_arm_reply_mode': 'When replying to a toot, the secondary toot button should:',
|
||||
'settings.side_arm_reply_mode.keep': 'Keep its set privacy',
|
||||
'settings.side_arm_reply_mode.copy': 'Copy privacy setting of the toot being replied to',
|
||||
'settings.side_arm_reply_mode.restrict': 'Restrict privacy setting to that of the toot being replied to',
|
||||
'settings.always_show_spoilers_field': 'Always enable the Content Warning field',
|
||||
'settings.prepend_cw_re': 'Prepend “re: ” to content warnings when replying',
|
||||
'settings.preselect_on_reply': 'Pre-select usernames on reply',
|
||||
'settings.preselect_on_reply_hint': 'When replying to a conversation with multiple participants, pre-select usernames past the first',
|
||||
'settings.confirm_missing_media_description': 'Show confirmation dialog before sending toots lacking media descriptions',
|
||||
'settings.confirm_before_clearing_draft': 'Show confirmation dialog before overwriting the message being composed',
|
||||
'settings.show_content_type_choice': 'Show content-type choice when authoring toots',
|
||||
'settings.content_warnings': 'Content Warnings',
|
||||
'settings.content_warnings.regexp': 'Regular expression',
|
||||
'settings.content_warnings_shared_state': 'Show/hide content of all copies at once',
|
||||
'settings.content_warnings_shared_state_hint': 'Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW',
|
||||
'settings.content_warnings_media_outside': 'Display media attachments outside content warnings',
|
||||
'settings.content_warnings_media_outside_hint': 'Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments',
|
||||
'settings.content_warnings_unfold_opts': 'Auto-unfolding options',
|
||||
'settings.enable_content_warnings_auto_unfold': 'Automatically unfold content-warnings',
|
||||
'settings.deprecated_setting': 'This setting is now controlled from Mastodon\'s {settings_page_link}',
|
||||
'settings.shared_settings_link': 'user preferences',
|
||||
'settings.content_warnings_filter': 'Content warnings to not automatically unfold:',
|
||||
'settings.layout_opts': 'Layout options',
|
||||
'settings.rewrite_mentions_no': 'Do not rewrite mentions',
|
||||
'settings.rewrite_mentions_acct': 'Rewrite with username and domain (when the account is remote)',
|
||||
'settings.rewrite_mentions_username': 'Rewrite with username',
|
||||
'settings.show_reply_counter': 'Display an estimate of the reply count',
|
||||
'settings.hicolor_privacy_icons': 'High color privacy icons',
|
||||
'settings.hicolor_privacy_icons.hint': 'Display privacy icons in bright and easily distinguishable colors',
|
||||
'settings.confirm_boost_missing_media_description': 'Show confirmation dialog before boosting toots lacking media descriptions',
|
||||
'settings.tag_misleading_links': 'Tag misleading links',
|
||||
'settings.tag_misleading_links.hint': 'Add a visual indication with the link target host to every link not mentioning it explicitly',
|
||||
'settings.rewrite_mentions': 'Rewrite mentions in displayed statuses',
|
||||
'settings.notifications_opts': 'Notifications options',
|
||||
'settings.notifications.tab_badge': 'Unread notifications badge',
|
||||
'settings.notifications.tab_badge.hint': 'Display a badge for unread notifications in the column icons when the notifications column isn\'t open',
|
||||
'settings.notifications.favicon_badge': 'Unread notifications favicon badge',
|
||||
'settings.notifications.favicon_badge.hint': 'Add a badge for unread notifications to the favicon',
|
||||
'settings.status_icons': 'Toot icons',
|
||||
'settings.status_icons_language': 'Language indicator',
|
||||
'settings.status_icons_reply': 'Reply indicator',
|
||||
'settings.status_icons_local_only': 'Local-only indicator',
|
||||
'settings.status_icons_media': 'Media and poll indicators',
|
||||
'settings.status_icons_visibility': 'Toot privacy indicator',
|
||||
'settings.layout': 'Layout:',
|
||||
'settings.image_backgrounds': 'Image backgrounds',
|
||||
'settings.image_backgrounds_media': 'Preview collapsed toot media',
|
||||
'settings.image_backgrounds_media_hint': 'If the post has any media attachment, use the first one as a background',
|
||||
'settings.image_backgrounds_users': 'Give collapsed toots an image background',
|
||||
'settings.media': 'Media',
|
||||
'settings.media_letterbox': 'Letterbox media',
|
||||
'settings.media_letterbox_hint': 'Scale down and letterbox media to fill the image containers instead of stretching and cropping them',
|
||||
'settings.media_fullwidth': 'Full-width media previews',
|
||||
'settings.inline_preview_cards': 'Inline preview cards for external links',
|
||||
'settings.media_reveal_behind_cw': 'Reveal sensitive media behind a CW by default',
|
||||
'settings.pop_in_player': 'Enable pop-in player',
|
||||
'settings.pop_in_position': 'Pop-in player position:',
|
||||
'settings.pop_in_left': 'Left',
|
||||
'settings.pop_in_right': 'Right',
|
||||
'settings.preferences': 'User preferences',
|
||||
'settings.wide_view': 'Wide view (Desktop mode only)',
|
||||
'settings.wide_view_hint': 'Stretches columns to better fill the available space.',
|
||||
'settings.navbar_under': 'Navbar at the bottom (Mobile only)',
|
||||
'status.collapse': 'Collapse',
|
||||
'status.react': 'React',
|
||||
'status.uncollapse': 'Uncollapse',
|
||||
'status.in_reply_to': 'This toot is a reply',
|
||||
'status.has_preview_card': 'Features an attached preview card',
|
||||
'status.has_pictures': 'Features attached pictures',
|
||||
'status.is_poll': 'This toot is a poll',
|
||||
'status.has_video': 'Features attached videos',
|
||||
'status.has_audio': 'Features attached audio files',
|
||||
'status.local_only': 'Only visible from your instance',
|
||||
|
||||
'content_type.change': 'Content type',
|
||||
'compose.content-type.html': 'HTML',
|
||||
'compose.content-type.markdown': 'Markdown',
|
||||
'compose.content-type.plain': 'Plain text',
|
||||
|
||||
'compose_form.poll.single_choice': 'Allow one choice',
|
||||
'compose_form.poll.multiple_choices': 'Allow multiple choices',
|
||||
'compose_form.spoiler': 'Hide text behind warning',
|
||||
|
||||
'column.toot': 'Toots and replies',
|
||||
'column_header.profile': 'Profile',
|
||||
'column.heading': 'Misc',
|
||||
'column.subheading': 'Miscellaneous options',
|
||||
'column_subheading.navigation': 'Navigation',
|
||||
'column_subheading.lists': 'Lists',
|
||||
|
||||
'media_gallery.sensitive': 'Sensitive',
|
||||
|
||||
'favourite_modal.combo': 'You can press {combo} to skip this next time',
|
||||
|
||||
'home.column_settings.show_direct': 'Show DMs',
|
||||
|
||||
'notification.markForDeletion': 'Mark for deletion',
|
||||
'notification.reaction': '{name} reacted to your post',
|
||||
'notifications.clear': 'Clear all my notifications',
|
||||
'notifications.column_settings.reaction': 'Reactions:',
|
||||
'notifications.marked_clear_confirmation': 'Are you sure you want to permanently clear all selected notifications?',
|
||||
'notifications.marked_clear': 'Clear selected notifications',
|
||||
|
||||
'notification_purge.start': 'Enter notification cleaning mode',
|
||||
'notification_purge.btn_all': 'Select\nall',
|
||||
'notification_purge.btn_none': 'Select\nnone',
|
||||
'notification_purge.btn_invert': 'Invert\nselection',
|
||||
'notification_purge.btn_apply': 'Clear\nselected',
|
||||
|
||||
'compose.attach.upload': 'Upload a file',
|
||||
'compose.attach.doodle': 'Draw something',
|
||||
'compose.attach': 'Attach...',
|
||||
|
||||
'advanced_options.local-only.short': 'Local-only',
|
||||
'advanced_options.local-only.long': 'Do not post to other instances',
|
||||
'advanced_options.local-only.tooltip': 'This post is local-only',
|
||||
'advanced_options.icon_title': 'Advanced options',
|
||||
'advanced_options.threaded_mode.short': 'Threaded mode',
|
||||
'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting',
|
||||
'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled',
|
||||
|
||||
'endorsed_accounts_editor.endorsed_accounts': 'Featured accounts',
|
||||
|
||||
'account.add_account_note': 'Add note for @{name}',
|
||||
'account_note.cancel': 'Cancel',
|
||||
'account_note.save': 'Save',
|
||||
'account_note.edit': 'Edit',
|
||||
'account_note.glitch_placeholder': 'No comment provided',
|
||||
'account.joined': 'Joined {date}',
|
||||
'account.follows': 'Follows',
|
||||
|
||||
'home.column_settings.advanced': 'Advanced',
|
||||
'home.column_settings.filter_regex': 'Filter out by regular expressions',
|
||||
'direct.group_by_conversations': 'Group by conversation',
|
||||
'community.column_settings.allow_local_only': 'Show local-only toots',
|
||||
|
||||
'keyboard_shortcuts.bookmark': 'to bookmark',
|
||||
'keyboard_shortcuts.toggle_collapse': 'to collapse/uncollapse toots',
|
||||
'keyboard_shortcuts.secondary_toot': 'to send toot using secondary privacy setting',
|
||||
|
||||
'tooltips.reactions': 'Reactions',
|
||||
};
|
||||
|
||||
export default Object.assign({}, inherited, messages);
|
|
@ -57,6 +57,7 @@
|
|||
"keyboard_shortcuts.bookmark": "to bookmark",
|
||||
"keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
|
||||
"keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
|
||||
"tooltips.reactions": "Reactions",
|
||||
"layout.auto": "Auto",
|
||||
"layout.desktop": "Desktop",
|
||||
"layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
|
||||
|
@ -71,6 +72,8 @@
|
|||
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"navigation_bar.misc": "Misc",
|
||||
"notification.markForDeletion": "Mark for deletion",
|
||||
"notification.reaction": "{name} reacted to your post",
|
||||
"notifications.column_settings.reaction": "Reactions:",
|
||||
"notification_purge.btn_all": "Select\nall",
|
||||
"notification_purge.btn_apply": "Clear\nselected",
|
||||
"notification_purge.btn_invert": "Invert\nselection",
|
||||
|
@ -124,6 +127,7 @@
|
|||
"settings.deprecated_setting": "This setting is now controlled from Mastodon's {settings_page_link}",
|
||||
"settings.enable_collapsed": "Enable collapsed toots",
|
||||
"settings.enable_collapsed_hint": "Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature",
|
||||
"settings.enter_amount_prompt": "Enter an amount",
|
||||
"settings.enable_content_warnings_auto_unfold": "Automatically unfold content-warnings",
|
||||
"settings.general": "General",
|
||||
"settings.hicolor_privacy_icons": "High color privacy icons",
|
||||
|
@ -137,6 +141,7 @@
|
|||
"settings.layout_opts": "Layout options",
|
||||
"settings.media": "Media",
|
||||
"settings.media_fullwidth": "Full-width media previews",
|
||||
"settings.num_visible_reactions": "Number of visible reactions",
|
||||
"settings.media_letterbox": "Letterbox media",
|
||||
"settings.media_letterbox_hint": "Scale down and letterbox media to fill the image containers instead of stretching and cropping them",
|
||||
"settings.media_reveal_behind_cw": "Reveal sensitive media behind a CW by default",
|
||||
|
@ -179,6 +184,7 @@
|
|||
"settings.wide_view": "Wide view (Desktop mode only)",
|
||||
"settings.wide_view_hint": "Stretches columns to better fill the available space.",
|
||||
"status.collapse": "Collapse",
|
||||
"status.react": "React",
|
||||
"status.has_audio": "Features attached audio files",
|
||||
"status.has_pictures": "Features attached pictures",
|
||||
"status.has_preview_card": "Features an attached preview card",
|
||||
|
|
12
app/javascript/flavours/glitch/locales/fr.js
Normal file
12
app/javascript/flavours/glitch/locales/fr.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import inherited from 'mastodon/locales/fr.json';
|
||||
|
||||
const messages = {
|
||||
'notification.reaction': '{name} a réagi·e à votre message',
|
||||
'notifications.column_settings.reaction': 'Réactions:',
|
||||
|
||||
'tooltips.reactions': 'Réactions',
|
||||
|
||||
'status.react': 'Réagir',
|
||||
};
|
||||
|
||||
export default Object.assign({}, inherited, messages);
|
|
@ -57,6 +57,7 @@
|
|||
"keyboard_shortcuts.bookmark": "ajouter aux marque-pages",
|
||||
"keyboard_shortcuts.secondary_toot": "Envoyer le post en utilisant les paramètres secondaires de confidentialité",
|
||||
"keyboard_shortcuts.toggle_collapse": "Plier/déplier les posts",
|
||||
"tooltips.reactions": "Réactions",
|
||||
"layout.auto": "Auto",
|
||||
"layout.desktop": "Ordinateur",
|
||||
"layout.hint.auto": "Choisir automatiquement la mise en page selon l'option \"Activer l'interface Web avancée\" et la taille d'écran.",
|
||||
|
@ -71,6 +72,8 @@
|
|||
"navigation_bar.keyboard_shortcuts": "Raccourcis clavier",
|
||||
"navigation_bar.misc": "Autres",
|
||||
"notification.markForDeletion": "Ajouter aux éléments à supprimer",
|
||||
"notification.reaction": "{name} a réagi·e à votre message",
|
||||
"notifications.column_settings.reaction": "Réactions:",
|
||||
"notification_purge.btn_all": "Sélectionner\ntout",
|
||||
"notification_purge.btn_apply": "Effacer\nla sélection",
|
||||
"notification_purge.btn_invert": "Inverser\nla sélection",
|
||||
|
@ -123,6 +126,7 @@
|
|||
"settings.deprecated_setting": "Cette option est maintenant définie par les {settings_page_link} de Mastodon",
|
||||
"settings.enable_collapsed": "Activer le repliement des posts",
|
||||
"settings.enable_collapsed_hint": "Les posts repliés ont une partie de leur contenu caché pour libérer de l'espace sur l'écran. C'est une option différente de l'avertissement de contenu",
|
||||
"settings.enter_amount_prompt": "Entrez un montant",
|
||||
"settings.enable_content_warnings_auto_unfold": "Déplier automatiquement les avertissements de contenu",
|
||||
"settings.general": "Général",
|
||||
"settings.hicolor_privacy_icons": "Indicateurs de confidentialité en couleurs",
|
||||
|
@ -136,6 +140,7 @@
|
|||
"settings.layout_opts": "Mise en page",
|
||||
"settings.media": "Média",
|
||||
"settings.media_fullwidth": "Utiliser toute la largeur pour les aperçus",
|
||||
"settings.num_visible_reactions": "Nombre de réactions visibles",
|
||||
"settings.media_letterbox": "Afficher les médias en Letterbox",
|
||||
"settings.media_letterbox_hint": "Réduit le média et utilise une letterbox pour afficher l'image entière plutôt que de l'étirer et de la rogner",
|
||||
"settings.media_reveal_behind_cw": "Toujours afficher les médias sensibles avec avertissement",
|
||||
|
@ -186,6 +191,7 @@
|
|||
"status.is_poll": "Ce post est un sondage",
|
||||
"status.local_only": "Visible uniquement depuis votre instance",
|
||||
"status.sensitive_toggle": "Cliquer pour voir",
|
||||
"status.react": "Réagir",
|
||||
"status.uncollapse": "Déplier",
|
||||
"web_app_crash.change_your_settings": "Changez vos {settings}",
|
||||
"web_app_crash.content": "Voici les différentes options qui s'offrent à vous :",
|
||||
|
|
|
@ -37,6 +37,7 @@ const initialState = ImmutableMap({
|
|||
follow: false,
|
||||
follow_request: false,
|
||||
favourite: false,
|
||||
reaction: false,
|
||||
reblog: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
|
@ -60,6 +61,7 @@ const initialState = ImmutableMap({
|
|||
follow_request: false,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
reaction: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
status: true,
|
||||
|
@ -73,6 +75,7 @@ const initialState = ImmutableMap({
|
|||
follow_request: false,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
reaction: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
status: true,
|
||||
|
|
|
@ -6,6 +6,11 @@ import {
|
|||
UNFAVOURITE_SUCCESS,
|
||||
BOOKMARK_REQUEST,
|
||||
BOOKMARK_FAIL,
|
||||
REACTION_UPDATE,
|
||||
REACTION_ADD_FAIL,
|
||||
REACTION_REMOVE_FAIL,
|
||||
REACTION_ADD_REQUEST,
|
||||
REACTION_REMOVE_REQUEST,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import {
|
||||
STATUS_MUTE_SUCCESS,
|
||||
|
@ -37,6 +42,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 initialState = ImmutableMap();
|
||||
|
||||
export default function statuses(state = initialState, action) {
|
||||
|
@ -63,6 +105,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 STATUS_MUTE_SUCCESS:
|
||||
return state.setIn([action.id, 'muted'], true);
|
||||
case STATUS_UNMUTE_SUCCESS:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import { createSelector } from 'reselect';
|
||||
import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
||||
|
|
|
@ -311,6 +311,10 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.detailed-status__button .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.relationship-tag {
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 4px;
|
||||
|
|
File diff suppressed because it is too large
Load diff
1813
app/javascript/flavours/glitch/styles/components/misc.scss
Normal file
1813
app/javascript/flavours/glitch/styles/components/misc.scss
Normal file
File diff suppressed because it is too large
Load diff
|
@ -463,6 +463,10 @@
|
|||
.notification__message {
|
||||
margin: -10px 0 10px;
|
||||
}
|
||||
|
||||
.reactions-bar--empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-favourite {
|
||||
|
@ -607,6 +611,10 @@
|
|||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
|
||||
& > .emoji-picker-dropdown > .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status__action-bar-button {
|
||||
|
@ -615,6 +623,10 @@
|
|||
&.icon-button--with-counter {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.fa-plus {
|
||||
padding-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.status__action-bar-dropdown {
|
||||
|
@ -681,6 +693,10 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px 0;
|
||||
|
||||
.fa-plus {
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__link {
|
||||
|
|
|
@ -41,6 +41,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));
|
||||
|
@ -412,3 +422,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,
|
||||
});
|
||||
|
|
|
@ -127,6 +127,7 @@ const excludeTypesFromFilter = filter => {
|
|||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reaction',
|
||||
'reblog',
|
||||
'mention',
|
||||
'poll',
|
||||
|
|
|
@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp';
|
|||
import DisplayName from './display_name';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusReactions from './status_reactions';
|
||||
import AttachmentList from './attachment_list';
|
||||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
@ -15,7 +16,7 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
|
|||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { displayMedia } from '../initial_state';
|
||||
import { displayMedia, visibleReactions } from '../initial_state';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
|
@ -64,6 +65,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
@ -76,6 +78,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,
|
||||
|
@ -99,6 +103,7 @@ class Status extends ImmutablePureComponent {
|
|||
cachedMediaWidth: PropTypes.number,
|
||||
scrollKey: PropTypes.string,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
|
@ -538,6 +543,15 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
{media}
|
||||
|
||||
<StatusReactions
|
||||
statusId={status.get('id')}
|
||||
reactions={status.get('reactions')}
|
||||
numVisible={visibleReactions}
|
||||
addReaction={this.props.onReactionAdd}
|
||||
removeReaction={this.props.onReactionRemove}
|
||||
canReact={this.context.identity.signedIn}
|
||||
/>
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,9 +6,10 @@ import IconButton from './icon_button';
|
|||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from '../initial_state';
|
||||
import { me, maxReactions } from '../initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -27,6 +28,7 @@ const messages = defineMessages({
|
|||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
|
@ -67,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
relationship: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReactionAdd: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
|
@ -128,6 +131,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;
|
||||
|
||||
|
@ -231,6 +238,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onFilter();
|
||||
};
|
||||
|
||||
handleNoOp = () => {} // hack for reaction add button
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
|
@ -357,11 +366,27 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
|
||||
);
|
||||
|
||||
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const reactButton = (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
|
||||
title={intl.formatMessage(messages.react)}
|
||||
disabled={!canReact}
|
||||
icon='plus'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' 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' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
{
|
||||
signedIn
|
||||
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
|
||||
: reactButton
|
||||
}
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||
|
||||
{shareButton}
|
||||
|
|
170
app/javascript/mastodon/components/status_reactions.js
Normal file
170
app/javascript/mastodon/components/status_reactions.js
Normal file
|
@ -0,0 +1,170 @@
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { autoPlayGif, reduceMotion } from '../initial_state';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
|
||||
import AnimatedNumber from './animated_number';
|
||||
import { assetHost } from '../utils/config';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||
import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
|
@ -16,6 +16,8 @@ import {
|
|||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
muteStatus,
|
||||
|
@ -66,6 +68,7 @@ const makeMapStateToProps = () => {
|
|||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, props),
|
||||
pictureInPicture: getPictureInPicture(state, props),
|
||||
emojiMap: makeCustomEmojiMap(state),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
|
@ -129,6 +132,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onReactionAdd (statusId, name, url) {
|
||||
dispatch(addReaction(statusId, name, url));
|
||||
},
|
||||
|
||||
onReactionRemove (statusId, name) {
|
||||
dispatch(removeReaction(statusId, name));
|
||||
},
|
||||
|
||||
onEmbed (status) {
|
||||
dispatch(openModal('EMBED', {
|
||||
url: status.get('url'),
|
||||
|
|
|
@ -316,6 +316,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -349,7 +350,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
};
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
|
|
|
@ -115,6 +115,17 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-reaction'>
|
||||
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reaction' defaultMessage='Reactions:' /></span>
|
||||
|
||||
<div className='column-settings__pillbar'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reaction']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reaction']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reaction']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reaction']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-mention'>
|
||||
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import Icon from 'mastodon/components/icon';
|
|||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
|
||||
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
|
@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent {
|
|||
>
|
||||
<Icon id='star' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('reaction')}
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='plus' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
|
|
|
@ -15,6 +15,7 @@ import classNames from 'classnames';
|
|||
|
||||
const messages = defineMessages({
|
||||
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
|
||||
reaction: { id: 'notification.reaction', defaultMessage: '{name} reacted to your status' },
|
||||
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
|
||||
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
||||
|
@ -213,6 +214,38 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderReaction (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='plus' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.reaction' defaultMessage='{name} reacted to your status' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderReblog (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
|
@ -429,6 +462,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderMention(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification, link);
|
||||
case 'reaction':
|
||||
return this.renderReaction(notification, link);
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification, link);
|
||||
case 'status':
|
||||
|
|
|
@ -5,9 +5,10 @@ import IconButton from '../../../components/icon_button';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { me } from '../../../initial_state';
|
||||
import { me, maxReactions } from '../../../initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -21,6 +22,7 @@ const messages = defineMessages({
|
|||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
|
||||
|
@ -62,6 +64,7 @@ class ActionBar extends React.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 +95,10 @@ class ActionBar extends React.PureComponent {
|
|||
this.props.onFavourite(this.props.status);
|
||||
};
|
||||
|
||||
handleEmojiPick = data => {
|
||||
this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''));
|
||||
}
|
||||
|
||||
handleBookmarkClick = (e) => {
|
||||
this.props.onBookmark(this.props.status, e);
|
||||
};
|
||||
|
@ -180,6 +187,8 @@ class ActionBar extends React.PureComponent {
|
|||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
handleNoOp = () => {} // hack for reaction add button
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
|
@ -257,6 +266,17 @@ class ActionBar extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const reactButton = (
|
||||
<IconButton
|
||||
className='plus-icon'
|
||||
onClick={this.handleNoOp} // EmojiPickerDropdown handles that
|
||||
title={intl.formatMessage(messages.react)}
|
||||
disabled={!canReact}
|
||||
icon='plus'
|
||||
/>
|
||||
);
|
||||
|
||||
const shareButton = ('share' in navigator) && publicStatus && (
|
||||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
|
||||
);
|
||||
|
@ -286,6 +306,13 @@ class ActionBar extends React.PureComponent {
|
|||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' 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' onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'>
|
||||
{
|
||||
canReact
|
||||
? <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
|
||||
: reactButton
|
||||
}
|
||||
</div>
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
{shareButton}
|
||||
|
|
|
@ -17,6 +17,7 @@ import Icon from 'mastodon/components/icon';
|
|||
import AnimatedNumber from 'mastodon/components/animated_number';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import StatusReactions from 'mastodon/components/status_reactions';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
|
@ -30,6 +31,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
@ -48,6 +50,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
available: PropTypes.bool,
|
||||
}),
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
onReactionAdd: PropTypes.func.isRequired,
|
||||
onReactionRemove: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -275,6 +279,14 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
{media}
|
||||
|
||||
<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'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
|
|
|
@ -30,6 +30,8 @@ import {
|
|||
unreblog,
|
||||
pin,
|
||||
unpin,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from '../../actions/interactions';
|
||||
import {
|
||||
replyCompose,
|
||||
|
@ -48,7 +50,7 @@ import { initMuteModal } from '../../actions/mutes';
|
|||
import { initBlockModal } from '../../actions/blocks';
|
||||
import { initBoostModal } from '../../actions/boosts';
|
||||
import { initReport } from '../../actions/reports';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
|
||||
import { makeCustomEmojiMap, makeGetStatus, makeGetPictureInPicture } from '../../selectors';
|
||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
|
@ -153,6 +155,7 @@ const makeMapStateToProps = () => {
|
|||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||
emojiMap: makeCustomEmojiMap(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -254,6 +257,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));
|
||||
|
@ -638,12 +654,15 @@ class Status extends ImmutablePureComponent {
|
|||
status={status}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
onReactionAdd={this.handleReactionAdd}
|
||||
onReactionRemove={this.handleReactionRemove}
|
||||
onToggleHidden={this.handleToggleHidden}
|
||||
onTranslate={this.handleTranslate}
|
||||
domain={domain}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
emojiMap={this.props.emojiMap}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
|
@ -651,6 +670,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}
|
||||
|
|
|
@ -83,7 +83,7 @@ class LinkFooter extends React.PureComponent {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mastodon</strong>:
|
||||
<strong>Mastodon, queer.af edition</strong>:
|
||||
{' '}
|
||||
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
|
||||
{DividingCircle}
|
||||
|
|
|
@ -61,6 +61,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
|
||||
|
@ -80,6 +81,7 @@
|
|||
* @property {boolean} use_blurhash
|
||||
* @property {boolean=} use_pending_items
|
||||
* @property {string} version
|
||||
* @property {number} visible_reactions
|
||||
* @property {boolean} translation_enabled
|
||||
*/
|
||||
|
||||
|
@ -114,6 +116,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');
|
||||
|
@ -132,6 +135,7 @@ export const unfollowModal = getMeta('unfollow_modal');
|
|||
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 translationEnabled = getMeta('translation_enabled');
|
||||
export const languages = initialState?.languages;
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
|
|
|
@ -397,6 +397,7 @@
|
|||
"notification.admin.report": "{name} meldete {target}",
|
||||
"notification.admin.sign_up": "{name} registrierte sich",
|
||||
"notification.favourite": "{name} hat deinen Beitrag favorisiert",
|
||||
"notification.reaction": "{name} hat auf deinen Beitrag reagiert",
|
||||
"notification.follow": "{name} folgt dir jetzt",
|
||||
"notification.follow_request": "{name} möchte dir folgen",
|
||||
"notification.mention": "{name} erwähnte dich",
|
||||
|
@ -411,6 +412,7 @@
|
|||
"notifications.column_settings.admin.sign_up": "Neue Registrierungen:",
|
||||
"notifications.column_settings.alert": "Desktop-Benachrichtigungen",
|
||||
"notifications.column_settings.favourite": "Favorisierungen:",
|
||||
"notifications.column_settings.reaction": "Reaktionen:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Erweiterte Filterleiste aktivieren",
|
||||
"notifications.column_settings.filter_bar.category": "Filterleiste:",
|
||||
"notifications.column_settings.filter_bar.show_bar": "Filterleiste anzeigen",
|
||||
|
@ -561,6 +563,7 @@
|
|||
"status.edited_x_times": "{count, plural, one {{count} mal} other {{count} mal}} bearbeitet",
|
||||
"status.embed": "Beitrag per iFrame einbetten",
|
||||
"status.favourite": "Favorisieren",
|
||||
"status.react": "Reagieren",
|
||||
"status.filter": "Beitrag filtern",
|
||||
"status.filtered": "Gefiltert",
|
||||
"status.hide": "Beitrag ausblenden",
|
||||
|
@ -619,6 +622,7 @@
|
|||
"timeline_hint.resources.statuses": "Ältere Beiträge",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} Profil} other {{counter} Profile}} {days, plural, one {seit gestern} other {in {days} Tagen}}",
|
||||
"trends.trending_now": "Aktuelle Trends",
|
||||
"tooltips.reactions": "Reaktionen",
|
||||
"ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
|
||||
"units.short.billion": "{count} Mrd",
|
||||
"units.short.million": "{count} Mio",
|
||||
|
|
|
@ -142,8 +142,8 @@
|
|||
"compose_form.poll.remove_option": "Remove this choice",
|
||||
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
|
||||
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
|
||||
"compose_form.publish": "Publish",
|
||||
"compose_form.publish_form": "Publish",
|
||||
"compose_form.publish": "Honk",
|
||||
"compose_form.publish_form": "Honk",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.save_changes": "Save changes",
|
||||
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
|
||||
|
@ -402,6 +402,7 @@
|
|||
"notification.admin.report": "{name} reported {target}",
|
||||
"notification.admin.sign_up": "{name} signed up",
|
||||
"notification.favourite": "{name} favourited your post",
|
||||
"notification.reaction": "{name} reacted to your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
|
@ -416,6 +417,7 @@
|
|||
"notifications.column_settings.admin.sign_up": "New sign-ups:",
|
||||
"notifications.column_settings.alert": "Desktop notifications",
|
||||
"notifications.column_settings.favourite": "Favourites:",
|
||||
"notifications.column_settings.reaction": "Reactions:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Display all categories",
|
||||
"notifications.column_settings.filter_bar.category": "Quick filter bar",
|
||||
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
|
||||
|
@ -566,6 +568,7 @@
|
|||
"status.edited_x_times": "Edited {count, plural, one {{count} time} other {{count} times}}",
|
||||
"status.embed": "Embed",
|
||||
"status.favourite": "Favourite",
|
||||
"status.react": "React",
|
||||
"status.filter": "Filter this post",
|
||||
"status.filtered": "Filtered",
|
||||
"status.hide": "Hide post",
|
||||
|
@ -594,7 +597,7 @@
|
|||
"status.sensitive_warning": "Sensitive content",
|
||||
"status.share": "Share",
|
||||
"status.show_filter_reason": "Show anyway",
|
||||
"status.show_less": "Show less",
|
||||
"status.show_less": "Wait no",
|
||||
"status.show_less_all": "Show less for all",
|
||||
"status.show_more": "Show more",
|
||||
"status.show_more_all": "Show more for all",
|
||||
|
@ -624,6 +627,7 @@
|
|||
"timeline_hint.resources.statuses": "Older posts",
|
||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
|
||||
"trends.trending_now": "Trending now",
|
||||
"tooltips.reactions": "Reactions",
|
||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||
"units.short.billion": "{count}B",
|
||||
"units.short.million": "{count}M",
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
"closed_registrations_modal.preamble": "Mastodon est décentralisé : peu importe où vous créez votre compte, vous serez en mesure de suivre et d'interagir avec quiconque sur ce serveur. Vous pouvez même l'héberger !",
|
||||
"closed_registrations_modal.title": "Inscription sur Mastodon",
|
||||
"column.about": "À propos",
|
||||
"column.blocks": "Comptes bloqués",
|
||||
"column.blocks": "Utilisateurs bloqués",
|
||||
"column.bookmarks": "Signets",
|
||||
"column.community": "Fil public local",
|
||||
"column.direct": "Messages directs",
|
||||
|
@ -139,7 +139,7 @@
|
|||
"compose_form.poll.switch_to_multiple": "Changer le sondage pour autoriser plusieurs choix",
|
||||
"compose_form.poll.switch_to_single": "Changer le sondage pour autoriser qu'un seul choix",
|
||||
"compose_form.publish": "Publier",
|
||||
"compose_form.publish_form": "Publier",
|
||||
"compose_form.publish_form": "Publish",
|
||||
"compose_form.publish_loud": "{publish} !",
|
||||
"compose_form.save_changes": "Enregistrer les modifications",
|
||||
"compose_form.sensitive.hide": "Marquer le média comme sensible",
|
||||
|
@ -536,8 +536,8 @@
|
|||
"search_results.statuses_fts_disabled": "La recherche de messages par leur contenu n'est pas activée sur ce serveur Mastodon.",
|
||||
"search_results.title": "Rechercher {q}",
|
||||
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
|
||||
"server_banner.about_active_users": "Personnes utilisant ce serveur au cours des 30 derniers jours (Comptes actifs mensuellement)",
|
||||
"server_banner.active_users": "comptes actifs",
|
||||
"server_banner.about_active_users": "Personnes utilisant ce serveur au cours des 30 derniers jours (Utilisateur·rice·s Actifs·ives Mensuellement)",
|
||||
"server_banner.active_users": "Utilisateurs actifs",
|
||||
"server_banner.administered_by": "Administré par :",
|
||||
"server_banner.introduction": "{domain} fait partie du réseau social décentralisé propulsé par {mastodon}.",
|
||||
"server_banner.learn_more": "En savoir plus",
|
||||
|
|
|
@ -33,6 +33,7 @@ const initialState = ImmutableMap({
|
|||
follow: false,
|
||||
follow_request: false,
|
||||
favourite: false,
|
||||
reaction: false,
|
||||
reblog: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
|
@ -56,6 +57,7 @@ const initialState = ImmutableMap({
|
|||
follow_request: false,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
reaction: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
status: true,
|
||||
|
@ -69,6 +71,7 @@ const initialState = ImmutableMap({
|
|||
follow_request: false,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
reaction: true,
|
||||
mention: true,
|
||||
poll: true,
|
||||
status: true,
|
||||
|
|
|
@ -6,6 +6,11 @@ import {
|
|||
UNFAVOURITE_SUCCESS,
|
||||
BOOKMARK_REQUEST,
|
||||
BOOKMARK_FAIL,
|
||||
REACTION_UPDATE,
|
||||
REACTION_ADD_FAIL,
|
||||
REACTION_REMOVE_FAIL,
|
||||
REACTION_ADD_REQUEST,
|
||||
REACTION_REMOVE_REQUEST,
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
STATUS_MUTE_SUCCESS,
|
||||
|
@ -35,6 +40,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 initialState = ImmutableMap();
|
||||
|
||||
export default function statuses(state = initialState, action) {
|
||||
|
@ -61,6 +103,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 STATUS_MUTE_SUCCESS:
|
||||
return state.setIn([action.id, 'muted'], true);
|
||||
case STATUS_UNMUTE_SUCCESS:
|
||||
|
|
|
@ -135,3 +135,11 @@ export const getAccountHidden = createSelector([
|
|||
], (hidden, followingOrRequested, isSelf) => {
|
||||
return hidden && !(isSelf || followingOrRequested);
|
||||
});
|
||||
|
||||
export const makeCustomEmojiMap = createSelector(
|
||||
[state => state.get('custom_emojis')],
|
||||
items => items.reduce(
|
||||
(map, emoji) => map.set(emoji.get('shortcode'), emoji),
|
||||
ImmutableMap(),
|
||||
),
|
||||
);
|
||||
|
|
1
app/javascript/skins/glitch/modern-dark/README.md
Normal file
1
app/javascript/skins/glitch/modern-dark/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
Adapted from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles/modern
|
189
app/javascript/skins/glitch/modern-dark/bits/glitch.scss
Normal file
189
app/javascript/skins/glitch/modern-dark/bits/glitch.scss
Normal file
|
@ -0,0 +1,189 @@
|
|||
// Remove the left padding
|
||||
#mastodon .status {
|
||||
--status-left-padding: 15px !important;
|
||||
}
|
||||
|
||||
/* Fixes */
|
||||
// While we wait for glitch update
|
||||
.account__header__bio .account__header__fields {
|
||||
margin-left: 15px !important;
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
.about__section__body,
|
||||
.account__header__bio {
|
||||
.account__header__fields {
|
||||
max-width: calc(100% - 30px) !important;
|
||||
|
||||
dl {
|
||||
display: block;
|
||||
}
|
||||
dd,
|
||||
dt {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
padding: 0;
|
||||
text-align: initial;
|
||||
}
|
||||
dt,
|
||||
dd.verified {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This fixes collapsed toots (we still override some parts bellow) but also
|
||||
// clicking on username when you open their profile because ".detailled-satatus__wrapper"
|
||||
// is at an another place and not with .focusable as expected upstream
|
||||
#mastodon .detailed-status__wrapper .status__content::before,
|
||||
#mastodon .detailed-status__wrapper .status__content::after {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
#mastodon {
|
||||
.status {
|
||||
// Modern sets the background to none,
|
||||
// so put one back for direct statuses
|
||||
&.status-direct {
|
||||
background: lighten($ui-base-color, 8%) !important;
|
||||
border-bottom-color: lighten($ui-base-color, 12%) !important;
|
||||
}
|
||||
|
||||
// Fix the collapsed gradiant so it stays inside the "card"
|
||||
&.collapsed {
|
||||
.status__content {
|
||||
overflow: hidden !important;
|
||||
|
||||
&:after {
|
||||
bottom: 0 !important;
|
||||
height: auto !important;
|
||||
left: 85px !important;
|
||||
right: 85px !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add some spacing around the role on the other side too
|
||||
.account-timeline__header .account-role {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
|
||||
// Make the @ address have a minimum width (so it wraps better)
|
||||
// And flex: 1 to push the role tag to the right
|
||||
.account__header__tabs__name h1 small {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
// Fix profile notes' textarea not usable at all
|
||||
.account__header__fields textarea,
|
||||
.account__header__account-note textarea {
|
||||
width: -webkit-fill-available !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
// The -10px here breaks a lot in Glitch, like collapsed statuses don't show
|
||||
// the basic info, or it's just too close to the user name. It's fine at 0.
|
||||
// Also fix alignement with a smaller padding-top.
|
||||
.notification__message,
|
||||
.status__prepend {
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 8px !important;
|
||||
}
|
||||
|
||||
// For the calc(), it's because it slightly cuts off the icons on the left due
|
||||
// to removing the big left padding.
|
||||
.status__prepend {
|
||||
margin-inline-start: calc(var(--status-left-padding) + 15px);
|
||||
}
|
||||
|
||||
// Since we have more elements on the action bar (including the date/time)
|
||||
// I'm reducing the padding a bit
|
||||
.detailed-status__action-bar .icon-button,
|
||||
.status__action-bar .icon-button {
|
||||
padding: .2em !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.status__action-bar-button {
|
||||
margin-right: 8px
|
||||
}
|
||||
|
||||
// Stop showing the text next to action buttons
|
||||
.icon-button[aria-label]:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
// Glitch still uses -- here while vanilla is __
|
||||
// so I'm just kinda copy-pasting the styles here
|
||||
.drawer--header {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
border-radius: var(--radius-round);
|
||||
margin-inline: 5px;
|
||||
overflow: hidden;
|
||||
border: 0!important;
|
||||
|
||||
& > * {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the background on the compose column transparent
|
||||
// which is better on mobile IMO
|
||||
.drawer__inner:not(.darker),
|
||||
.drawer__inner__mastodon {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
// The cancel button when replying was inaccessible
|
||||
.compose-form .reply-indicator__header {
|
||||
margin-bottom: -5px;
|
||||
|
||||
.account {
|
||||
border-radius: 0!important; // Fix weird radius
|
||||
display: inline-block;
|
||||
width: calc(100% - 1.28571em - 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.pillbar-button {
|
||||
// Fix unreadable options in the column settings when not selected
|
||||
&:not(.active) {
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
// And remove ugly box-shadow
|
||||
&:not([disabled]).active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 895px) {
|
||||
#mastodon .status__action-bar {
|
||||
// Reverting the margin on elements that were negative due to the
|
||||
// big padding the theme used to have
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Some elements went above floating menus or PiP
|
||||
// (like the spoiler media button or profile pictures)
|
||||
.privacy-dropdown__dropdown,
|
||||
.language-dropdown__dropdown,
|
||||
.emoji-picker-dropdown__menu,
|
||||
.picture-in-picture {
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
// Fix alignement in the sharing/following popups
|
||||
.modal-layout.modal-layout .container-alt {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
// This was used in mastodon.coffee, I like it
|
||||
.layout-multiple-columns .column {
|
||||
flex-grow: 1;
|
||||
max-width: 500px;
|
||||
}
|
3690
app/javascript/skins/glitch/modern-dark/bits/style.scss
Normal file
3690
app/javascript/skins/glitch/modern-dark/bits/style.scss
Normal file
File diff suppressed because it is too large
Load diff
60
app/javascript/skins/glitch/modern-dark/common.scss
Normal file
60
app/javascript/skins/glitch/modern-dark/common.scss
Normal file
|
@ -0,0 +1,60 @@
|
|||
// Commonly used web colors
|
||||
$black: #000000; // Black
|
||||
$white: #ffffff; // White
|
||||
$success-green: #79bd9a !default; // Padua
|
||||
$error-red: #df405a !default; // Cerise
|
||||
$warning-red: #ff5050 !default; // Sunset Orange
|
||||
$gold-star: #ca8f04 !default; // Dark Goldenrod
|
||||
|
||||
$red-bookmark: $warning-red;
|
||||
|
||||
// Values from the classic Mastodon UI
|
||||
$classic-base-color: #282c37; // Midnight Express
|
||||
$classic-primary-color: #9baec8; // Echo Blue
|
||||
$classic-secondary-color: #d9e1e8; // Pattens Blue
|
||||
$classic-highlight-color: #e7b01c; // Summer Sky
|
||||
|
||||
// Variables for defaults in UI
|
||||
$base-shadow-color: $black !default;
|
||||
$base-overlay-background: $black !default;
|
||||
$base-border-color: $white !default;
|
||||
$simple-background-color: $white !default;
|
||||
$valid-value-color: $success-green !default;
|
||||
$error-value-color: $error-red !default;
|
||||
|
||||
// Tell UI to use selected colors
|
||||
$ui-base-color: $classic-base-color !default; // Darkest
|
||||
$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
|
||||
$ui-primary-color: $classic-primary-color !default; // Lighter
|
||||
$ui-secondary-color: $classic-secondary-color !default; // Lightest
|
||||
$ui-highlight-color: $classic-highlight-color !default;
|
||||
|
||||
// Variables for texts
|
||||
$primary-text-color: $white !default;
|
||||
$darker-text-color: $ui-primary-color !default;
|
||||
$dark-text-color: $ui-base-lighter-color !default;
|
||||
$secondary-text-color: $ui-secondary-color !default;
|
||||
$highlight-text-color: $ui-highlight-color !default;
|
||||
$action-button-color: $ui-base-lighter-color !default;
|
||||
// For texts on inverted backgrounds
|
||||
$inverted-text-color: $ui-base-color !default;
|
||||
$lighter-text-color: $ui-base-lighter-color !default;
|
||||
$light-text-color: $ui-primary-color !default;
|
||||
|
||||
// Language codes that uses CJK fonts
|
||||
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
|
||||
|
||||
// Variables for components
|
||||
$media-modal-media-max-width: 100%;
|
||||
// put margins on top and bottom of image to avoid the screen covered by image.
|
||||
$media-modal-media-max-height: 80%;
|
||||
|
||||
$no-gap-breakpoint: 415px;
|
||||
|
||||
$font-sans-serif: 'mastodon-font-sans-serif' !default;
|
||||
$font-display: 'mastodon-font-display' !default;
|
||||
$font-monospace: 'mastodon-font-monospace' !default;
|
||||
|
||||
@import 'flavours/glitch/styles/index';
|
||||
@import 'bits/style';
|
||||
@import 'bits/glitch';
|
4
app/javascript/skins/glitch/modern-dark/names.yml
Normal file
4
app/javascript/skins/glitch/modern-dark/names.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
en:
|
||||
skins:
|
||||
glitch:
|
||||
modern-dark: Modern (dark) by freeplay
|
3
app/javascript/skins/glitch/modern-light/README.md
Normal file
3
app/javascript/skins/glitch/modern-light/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
Depends upon presence of the modern-dark theme
|
||||
|
||||
Adapted from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles/modern
|
46
app/javascript/skins/glitch/modern-light/common.scss
Normal file
46
app/javascript/skins/glitch/modern-light/common.scss
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Dependent colors
|
||||
$black: #000000;
|
||||
$white: #ffffff;
|
||||
|
||||
$classic-base-color: #282c37;
|
||||
$classic-primary-color: #9baec8;
|
||||
$classic-secondary-color: #d9e1e8;
|
||||
$classic-highlight-color: #e7b01c;
|
||||
|
||||
// Differences
|
||||
$success-green: lighten(#3c754d, 8%);
|
||||
|
||||
$base-overlay-background: $white !default;
|
||||
$valid-value-color: $success-green !default;
|
||||
|
||||
$ui-base-color: $classic-secondary-color !default;
|
||||
$ui-base-lighter-color: #b0c0cf;
|
||||
$ui-primary-color: #9bcbed;
|
||||
$ui-secondary-color: $classic-base-color !default;
|
||||
$ui-highlight-color: #e7b01c;
|
||||
|
||||
$primary-text-color: $black !default;
|
||||
$darker-text-color: $classic-base-color !default;
|
||||
$dark-text-color: #444b5d;
|
||||
$action-button-color: #606984;
|
||||
|
||||
$inverted-text-color: $black !default;
|
||||
$lighter-text-color: $classic-base-color !default;
|
||||
$light-text-color: #444b5d;
|
||||
|
||||
//Newly added colors
|
||||
$account-background-color: $white !default;
|
||||
|
||||
//Invert darkened and lightened colors
|
||||
@function darken($color, $amount) {
|
||||
@return hsl(hue($color), saturation($color), lightness($color) + $amount);
|
||||
}
|
||||
|
||||
@function lighten($color, $amount) {
|
||||
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
|
||||
}
|
||||
|
||||
|
||||
@import 'flavours/glitch/styles/index';
|
||||
@import '../modern-dark/bits/style';
|
||||
@import '../modern-dark/bits/glitch';
|
4
app/javascript/skins/glitch/modern-light/names.yml
Normal file
4
app/javascript/skins/glitch/modern-light/names.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
en:
|
||||
skins:
|
||||
glitch:
|
||||
modern-light: Modern (light) by freeplay
|
71
app/javascript/skins/glitch/queer-af/common.scss
Normal file
71
app/javascript/skins/glitch/queer-af/common.scss
Normal file
|
@ -0,0 +1,71 @@
|
|||
// Commonly used web colors
|
||||
$black: #000000; // Black
|
||||
$white: #ffffff; // White
|
||||
$success-green: #79bd9a !default; // Padua
|
||||
$error-red: #df405a !default; // Cerise
|
||||
$warning-red: #ff5050 !default; // Sunset Orange
|
||||
$gold-star: #ca8f04 !default; // Dark Goldenrod
|
||||
|
||||
$red-bookmark: $warning-red;
|
||||
|
||||
// Values from the classic Mastodon UI
|
||||
$classic-base-color: #291533;
|
||||
$classic-primary-color: #9902de;
|
||||
$classic-secondary-color: #47de02;
|
||||
$classic-highlight-color: #3bbf01;
|
||||
|
||||
// Variables for defaults in UI
|
||||
$base-shadow-color: $black !default;
|
||||
$base-overlay-background: $black !default;
|
||||
$base-border-color: $white !default;
|
||||
$simple-background-color: $white !default;
|
||||
$valid-value-color: $success-green !default;
|
||||
$error-value-color: $error-red !default;
|
||||
|
||||
// Tell UI to use selected colors
|
||||
$ui-base-color: $classic-base-color !default; // Darkest
|
||||
$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
|
||||
$ui-primary-color: $classic-primary-color !default; // Lighter
|
||||
$ui-secondary-color: $classic-secondary-color !default; // Lightest
|
||||
$ui-highlight-color: $classic-highlight-color !default;
|
||||
|
||||
// Variables for texts
|
||||
$primary-text-color: $white !default;
|
||||
$darker-text-color: $ui-primary-color !default;
|
||||
$dark-text-color: $ui-base-lighter-color !default;
|
||||
$secondary-text-color: $ui-secondary-color !default;
|
||||
$highlight-text-color: $ui-highlight-color !default;
|
||||
$action-button-color: $ui-base-lighter-color !default;
|
||||
// For texts on inverted backgrounds
|
||||
$inverted-text-color: $ui-base-color !default;
|
||||
$lighter-text-color: $ui-base-lighter-color !default;
|
||||
$light-text-color: $ui-primary-color !default;
|
||||
|
||||
// Language codes that uses CJK fonts
|
||||
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
|
||||
|
||||
// Variables for components
|
||||
$media-modal-media-max-width: 100%;
|
||||
// put margins on top and bottom of image to avoid the screen covered by image.
|
||||
$media-modal-media-max-height: 80%;
|
||||
|
||||
$no-gap-breakpoint: 415px;
|
||||
|
||||
$font-sans-serif: 'TransportNew' !default;
|
||||
$font-display: 'TransportNew' !default;
|
||||
$font-monospace: 'mastodon-font-monospace' !default;
|
||||
|
||||
|
||||
@import 'flavours/glitch/styles/index';
|
||||
@import '../modern-dark/bits/style';
|
||||
@import '../modern-dark/bits/glitch';
|
||||
|
||||
.status__content,
|
||||
.account__header__content {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.prose {
|
||||
color: #fff;
|
||||
font-weight: 200;
|
||||
}
|
4
app/javascript/skins/glitch/queer-af/names.yml
Normal file
4
app/javascript/skins/glitch/queer-af/names.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
en:
|
||||
skins:
|
||||
glitch:
|
||||
queer-af: queer.af, based upon modern-dark
|
517
app/javascript/skins/glitch/space/common.scss
Normal file
517
app/javascript/skins/glitch/space/common.scss
Normal file
|
@ -0,0 +1,517 @@
|
|||
// im-in.space theme
|
||||
// taken from https://github.com/im-in-space/mastodon/tree/im-in.space/app/javascript/styles
|
||||
|
||||
// Might be missing
|
||||
$classic-base-color: #282c37;
|
||||
|
||||
// Our color
|
||||
$classic-highlight-color: #ff9800;
|
||||
$ui-highlight-color: $classic-highlight-color;
|
||||
$highlight-text-color: $ui-highlight-color;
|
||||
|
||||
@import 'flavours/glitch/styles/index';
|
||||
|
||||
// Some changes to the about pages
|
||||
.about-body .wrapper {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
padding: 50px;
|
||||
|
||||
ol {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
// Make UI fluid for large width (is useless for glitch)
|
||||
// 1338px, we failed by ONE pixel
|
||||
@media screen and (min-width: 1338px) {
|
||||
body.layout-multiple-columns:not(.flavour-glitch) .column,
|
||||
body.layout-multiple-columns:not(.flavour-glitch) .column__wrapper {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Background for DMs
|
||||
.status.status-direct {
|
||||
background: #313543 !important;
|
||||
}
|
||||
|
||||
// Background for the gradient effect on collapsed toots
|
||||
.status.collapsed .status__content:after {
|
||||
background: linear-gradient(rgba(34, 34, 51, 0), #223);
|
||||
}
|
||||
|
||||
// Make spoiler link button readable
|
||||
.status__content .status__content__spoiler-link {
|
||||
color: #37aac0;
|
||||
}
|
||||
|
||||
// Revert margin to make the app scrollable horizontaly
|
||||
.columns-area {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Removing some background
|
||||
.drawer {
|
||||
.drawer--header,
|
||||
.contents {
|
||||
background: transparent !important;
|
||||
|
||||
// Remove that outline when clicked
|
||||
.mastodon {
|
||||
outline: 0;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.columns-area__panels__pane--navigational .navigation-panel,
|
||||
.getting-started {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
// More random fixes to the base theme
|
||||
.tabs-bar__wrapper {
|
||||
background-color: #223;
|
||||
}
|
||||
.compose-form__buttons {
|
||||
button.icon-button.inverted {
|
||||
color: #606984;
|
||||
}
|
||||
|
||||
button.icon-button.inverted.active {
|
||||
color: #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
.endorsements-widget {
|
||||
background: #223;
|
||||
-webkit-box-shadow: 0 0 15px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, .2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
*/
|
||||
|
||||
.rich-formatting {
|
||||
strong, b {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-page__information,
|
||||
.landing-page__forms,
|
||||
.landing-page #mastodon-timeline,
|
||||
.box-widget,
|
||||
.contact-widget,
|
||||
.landing-page__information.contact-widget,
|
||||
.landing-page__call-to-action,
|
||||
.hero-widget__text,
|
||||
.hero-widget__footer,
|
||||
.table-of-contents,
|
||||
.public-account-bio,
|
||||
.public-account-header__bar::before,
|
||||
.directory__card__bar,
|
||||
.directory__card__extra,
|
||||
.directory__tag > a,
|
||||
.directory__tag > div {
|
||||
background-color: #223 !important;
|
||||
}
|
||||
|
||||
.button.logo-button {
|
||||
background-color: #ff9800 !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #ffa51f !important;
|
||||
|
||||
svg {
|
||||
path {
|
||||
&:last-child {
|
||||
fill: #ffa51f !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
path {
|
||||
&:last-child {
|
||||
fill: #ff9800 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.simple_form {
|
||||
button,
|
||||
.button,
|
||||
.block-button {
|
||||
background-color: #ff9800 !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: #ffa51f !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button.button-alternative {
|
||||
background: #29293d !important;
|
||||
color: #37aac0 !important;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background: #29293d !important;
|
||||
color: #37aac0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// (v1.16) https://userstyles.org/styles/140852/mastodon-flat-dark-and-colourful
|
||||
.button {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus,
|
||||
.button:active,
|
||||
.button:disabled {
|
||||
background-color: #ffa51f;
|
||||
}
|
||||
|
||||
.ui {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.drawer .drawer__header,
|
||||
.column-header,
|
||||
.column-icon {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.column-icon {
|
||||
color: #eee;
|
||||
transition: all 200ms;
|
||||
}
|
||||
|
||||
.column-icon:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.column-icon.collapsable {
|
||||
background-color: #3aaacf;
|
||||
color: #fff;
|
||||
font-size: 20px !important;
|
||||
padding-bottom: 13px !important;
|
||||
}
|
||||
|
||||
.column-settings--outer,
|
||||
.column-settings--section,
|
||||
.setting-toggle {
|
||||
background-color: #3aaacf;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-text,
|
||||
.setting-text:hover,
|
||||
.setting-text:focus {
|
||||
color: #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-track,
|
||||
.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.react-toggle--checked .react-toggle-thumb {
|
||||
border-color: #223;
|
||||
}
|
||||
|
||||
.drawer .drawer__header .drawer__tab {
|
||||
color: #3aaacf;
|
||||
}
|
||||
|
||||
.drawer .drawer__header a:hover {
|
||||
background-color: #223;
|
||||
color: #61b4cf;
|
||||
}
|
||||
|
||||
.search__input {
|
||||
background-color: #223;
|
||||
transition: all 200ms;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search__input:hover,
|
||||
.search__input:focus {
|
||||
background-color: #eee;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.drawer__inner {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.drawer__inner__mastodon {
|
||||
background-color: #223 !important;
|
||||
}
|
||||
|
||||
.navigation-bar {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.navigation-bar strong {
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.search-results__header {
|
||||
background-color: #223;
|
||||
color: #eee;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.drawer__inner.darker {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.account__display-name {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.display-name span {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.icon-button:focus,
|
||||
.icon-button:active,
|
||||
.icon-button.active {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.activity-stream .entry,
|
||||
.status__prepend,
|
||||
.status,
|
||||
.column > .scrollable {
|
||||
background-color: #223;
|
||||
color: #fff;
|
||||
// border-bottom: 0;
|
||||
}
|
||||
|
||||
.notification__message {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.notification__filter-bar,
|
||||
.notification__filter-bar button,
|
||||
.account__section-headline button {
|
||||
background: #223;
|
||||
}
|
||||
|
||||
.status .status__relative-time {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.status__content a .fa,
|
||||
.status__content a .fa:hover {
|
||||
color: #3aaacf;
|
||||
}
|
||||
|
||||
.status__content a,
|
||||
.reply-indicator__content a,
|
||||
.getting-started a,
|
||||
.muted .status__content a,
|
||||
.account__header .account__header__username {
|
||||
color: #3aaacf;
|
||||
}
|
||||
|
||||
.muted .status__display-name strong {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.status__prepend .status__display-name strong {
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.muted .status__content p {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.column-link,
|
||||
.column {
|
||||
background-color: #223;
|
||||
}
|
||||
|
||||
.account__action-bar,
|
||||
.account__action-bar__tab,
|
||||
.detailed-status__action-bar {
|
||||
border: 0;
|
||||
background: #223;
|
||||
}
|
||||
|
||||
.account__section-headline {
|
||||
background: #223;
|
||||
}
|
||||
|
||||
.account {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.column-back-button {
|
||||
background-color: #223;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.status__content .status__content__spoiler-link,
|
||||
.reply-indicator__content .status__content__spoiler-link {
|
||||
background: #29293d;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions__item:hover {
|
||||
background: #3aaacf;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__suggestions__item.selected {
|
||||
background: #3aaacf;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
background: #29293d;
|
||||
}
|
||||
|
||||
.reply-indicator__content {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.reply-indicator .reply-indicator__display-name {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.search__icon .fa {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
background: #223;
|
||||
}
|
||||
|
||||
/*
|
||||
.account__header {
|
||||
-webkit-box-flex: 0;
|
||||
-ms-flex: 0 0 auto;
|
||||
flex: 0 0 auto;
|
||||
background: #223;
|
||||
text-align: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.account__header > div {
|
||||
background: linear-gradient(to bottom, rgba(34, 34, 51, 0.10), #223);
|
||||
}
|
||||
*/
|
||||
|
||||
.icon-button.inverted {
|
||||
color: #3aaacf
|
||||
}
|
||||
|
||||
.icon-button.inverted.active {
|
||||
color: #3aaacf
|
||||
}
|
||||
|
||||
.icon-button.inverted:hover,
|
||||
.icon-button.inverted:active,
|
||||
.icon-button.inverted:focus {
|
||||
color: #61b4cf
|
||||
}
|
||||
|
||||
.text-icon-button.active {
|
||||
color: #ff9800
|
||||
}
|
||||
|
||||
.privacy-dropdown__option.active {
|
||||
background: #3aaacf;
|
||||
}
|
||||
|
||||
.privacy-dropdown__option:hover {
|
||||
background: #3aaacf;
|
||||
}
|
||||
|
||||
.privacy-dropdown__option.active:hover {
|
||||
background: #3aaacf;
|
||||
}
|
||||
|
||||
.column-header__button {
|
||||
background: #223;
|
||||
}
|
||||
|
||||
.column-header__button.active,
|
||||
.column-header__button.active:hover {
|
||||
background: #3aaacf;
|
||||
}
|
||||
|
||||
.column-header__collapsible-inner {
|
||||
background: #3aaacf;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.column-settings__section {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-meta__label,
|
||||
.setting-toggle__label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.column-subheading {
|
||||
background: #223;
|
||||
}
|
||||
|
||||
button.active .fa-retweet::after {
|
||||
color: #ff9800;
|
||||
content: "\f079";
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.icon-button:focus,
|
||||
.icon-button:active,
|
||||
.icon-button.active {
|
||||
color: #3aaacf;
|
||||
}
|
||||
|
||||
.active .fa-star::before,
|
||||
.fa.fa-fw.fa-star.star-icon::before,
|
||||
.fa.fa-fw.fa-user-times {
|
||||
color: #3aaacf;
|
||||
}
|
||||
|
||||
.column-header__back-button {
|
||||
background: #223;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.column-header__back-button:hover {
|
||||
color: #ffa51f;
|
||||
}
|
4
app/javascript/skins/glitch/space/names.yml
Normal file
4
app/javascript/skins/glitch/space/names.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
en:
|
||||
skins:
|
||||
glitch:
|
||||
space: im-in.space
|
|
@ -1145,6 +1145,10 @@ body > [data-popper-placement] {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reactions-bar--empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status__relative-time {
|
||||
|
@ -1268,6 +1272,16 @@ body > [data-popper-placement] {
|
|||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
|
||||
& > .emoji-picker-dropdown > .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status__action-bar-button {
|
||||
.fa-plus {
|
||||
padding-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__action-bar-dropdown {
|
||||
|
@ -4087,6 +4101,10 @@ a.status-card.compact:hover {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.detailed-status__button .emoji-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.column-settings__outer {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
padding: 15px;
|
||||
|
|
|
@ -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,33 @@ 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 all emojis 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(tags)
|
||||
as_array(tags).each do |tag|
|
||||
process_single_emoji tag if tag['type'] == 'Emoji'
|
||||
end
|
||||
end
|
||||
|
||||
def process_single_emoji(tag)
|
||||
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
|
||||
return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
|
||||
|
||||
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
|
||||
return 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}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
25
app/lib/activitypub/activity/emoji_react.rb
Normal file
25
app/lib/activitypub/activity/emoji_react.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::EmojiReact < ActivityPub::Activity
|
||||
def perform
|
||||
original_status = status_from_uri(object_uri)
|
||||
name = @json['content']
|
||||
return if original_status.nil? ||
|
||||
!original_status.account.local? ||
|
||||
delete_arrived_first?(@json['id']) ||
|
||||
@account.reacted?(original_status, name)
|
||||
|
||||
custom_emoji = nil
|
||||
if name =~ /^:.*:$/
|
||||
process_emoji_tags(@json['tag'])
|
||||
|
||||
name.delete! ':'
|
||||
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
|
||||
return if custom_emoji.nil?
|
||||
end
|
||||
|
||||
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')
|
||||
end
|
||||
end
|
|
@ -3,12 +3,36 @@
|
|||
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_misskey_reaction(original_status)
|
||||
|
||||
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
|
||||
|
||||
# Misskey delivers reactions as likes with the emoji in _misskey_reaction
|
||||
# see https://misskey-hub.net/ns.html#misskey-reaction for details
|
||||
def maybe_process_misskey_reaction(original_status)
|
||||
name = @json['_misskey_reaction']
|
||||
return false if name.nil?
|
||||
|
||||
custom_emoji = nil
|
||||
if name =~ /^:.*:$/
|
||||
process_emoji_tags(@json['tag'])
|
||||
|
||||
name.delete! ':'
|
||||
custom_emoji = CustomEmoji.find_by(shortcode: name, domain: @account.domain)
|
||||
return false if custom_emoji.nil? # invalid custom emoji, treat it as a regular like
|
||||
end
|
||||
return true if @account.reacted?(original_status, name)
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
@ -113,6 +115,22 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
|||
end
|
||||
end
|
||||
|
||||
def undo_emoji_react
|
||||
name = @object['content']
|
||||
return if name.nil?
|
||||
|
||||
status = status_from_uri(target_uri)
|
||||
|
||||
return if status.nil? || !status.account.local?
|
||||
|
||||
if @account.reacted?(status, name.delete(':'))
|
||||
reaction = status.status_reactions.where(account: @account, name: name).first
|
||||
reaction&.destroy
|
||||
else
|
||||
delete_later!(object_uri)
|
||||
end
|
||||
end
|
||||
|
||||
def undo_block
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ class UserSettingsDecorator
|
|||
user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items')
|
||||
user.settings['trends'] = trends_preference if change?('setting_trends')
|
||||
user.settings['crop_images'] = crop_images_preference if change?('setting_crop_images')
|
||||
user.settings['visible_reactions'] = visible_reactions_preference if change?('setting_visible_reactions')
|
||||
user.settings['always_send_emails'] = always_send_emails_preference if change?('setting_always_send_emails')
|
||||
end
|
||||
|
||||
|
@ -158,6 +159,10 @@ class UserSettingsDecorator
|
|||
boolean_cast_setting 'setting_crop_images'
|
||||
end
|
||||
|
||||
def visible_reactions_preference
|
||||
integer_cast_setting('setting_visible_reactions', 0)
|
||||
end
|
||||
|
||||
def always_send_emails_preference
|
||||
boolean_cast_setting 'setting_always_send_emails'
|
||||
end
|
||||
|
@ -166,6 +171,15 @@ class UserSettingsDecorator
|
|||
ActiveModel::Type::Boolean.new.cast(settings[key])
|
||||
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
|
||||
|
||||
def coerced_settings(key)
|
||||
coerce_values settings.fetch(key, {})
|
||||
end
|
||||
|
|
|
@ -237,6 +237,10 @@ module AccountInteractions
|
|||
status.proper.favourites.where(account: self).exists?
|
||||
end
|
||||
|
||||
def reacted?(status, name)
|
||||
status.proper.status_reactions.where(account: self, name: name).exists?
|
||||
end
|
||||
|
||||
def bookmarked?(status)
|
||||
status.proper.bookmarks.where(account: self).exists?
|
||||
end
|
||||
|
|
|
@ -20,12 +20,13 @@ class Notification < ApplicationRecord
|
|||
include Paginable
|
||||
|
||||
LEGACY_TYPE_CLASS_MAP = {
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'Poll' => :poll,
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'StatusReaction' => :reaction,
|
||||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
TYPES = %i(
|
||||
|
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
|
|||
follow
|
||||
follow_request
|
||||
favourite
|
||||
reaction
|
||||
poll
|
||||
update
|
||||
admin.sign_up
|
||||
|
@ -46,6 +48,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],
|
||||
|
@ -55,13 +58,14 @@ class Notification < ApplicationRecord
|
|||
belongs_to :from_account, class_name: 'Account', optional: true
|
||||
belongs_to :activity, polymorphic: true, optional: true
|
||||
|
||||
belongs_to :mention, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :report, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :mention, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :report, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status_reaction, foreign_key: 'activity_id', optional: true
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
|
@ -79,6 +83,8 @@ class Notification < ApplicationRecord
|
|||
status&.reblog
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :reaction
|
||||
status_reaction&.status
|
||||
when :mention
|
||||
mention&.status
|
||||
when :poll
|
||||
|
@ -128,6 +134,8 @@ class Notification < ApplicationRecord
|
|||
notification.status.reblog = cached_status
|
||||
when :favourite
|
||||
notification.favourite.status = cached_status
|
||||
when :reaction
|
||||
notification.reaction.status = cached_status
|
||||
when :mention
|
||||
notification.mention.status = cached_status
|
||||
when :poll
|
||||
|
@ -139,6 +147,8 @@ class Notification < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
alias reaction status_reaction
|
||||
|
||||
after_initialize :set_from_account
|
||||
before_validation :set_from_account
|
||||
|
||||
|
@ -148,7 +158,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
|
||||
|
|
|
@ -72,6 +72,7 @@ class Status < ApplicationRecord
|
|||
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
|
||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
has_many :status_reactions, inverse_of: :status, dependent: :destroy
|
||||
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
|
@ -264,6 +265,21 @@ class Status < ApplicationRecord
|
|||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
|
||||
end
|
||||
|
||||
def reactions(account = nil)
|
||||
records = begin
|
||||
scope = status_reactions.group(:status_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
|
||||
|
||||
if account.nil?
|
||||
scope.select('name, custom_emoji_id, count(*) as count, false as me')
|
||||
else
|
||||
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from status_reactions r where r.account_id = #{account.id} and r.status_id = status_reactions.status_id and r.name = status_reactions.name) as me")
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
|
||||
records
|
||||
end
|
||||
|
||||
def ordered_media_attachments
|
||||
if ordered_media_attachment_ids.nil?
|
||||
media_attachments
|
||||
|
|
31
app/models/status_reaction.rb
Normal file
31
app/models/status_reaction.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
# 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 :set_custom_emoji
|
||||
|
||||
private
|
||||
|
||||
def set_custom_emoji
|
||||
self.custom_emoji = CustomEmoji.find_by(shortcode: name, domain: account.domain) if name.blank?
|
||||
end
|
||||
end
|
|
@ -135,7 +135,7 @@ class User < ApplicationRecord
|
|||
delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
|
||||
:reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_followers_count,
|
||||
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
|
||||
:advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
|
||||
:advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :visible_reactions,
|
||||
:disable_swiping, :always_send_emails, :default_content_type, :system_emoji_font,
|
||||
to: :settings, prefix: :setting, allow_nil: false
|
||||
|
||||
|
|
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,7 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
attributes :meta, :compose, :accounts,
|
||||
:media_attachments, :settings,
|
||||
:max_toot_chars, :poll_limits,
|
||||
:languages
|
||||
:languages, :max_reactions
|
||||
|
||||
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||
has_one :role, serializer: REST::RoleSerializer
|
||||
|
@ -15,6 +15,10 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
StatusLengthValidator::MAX_CHARS
|
||||
end
|
||||
|
||||
def max_reactions
|
||||
StatusReactionValidator::LIMIT
|
||||
end
|
||||
|
||||
def poll_limits
|
||||
{
|
||||
max_options: PollValidator::MAX_OPTIONS,
|
||||
|
@ -67,6 +71,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
store[:default_content_type] = object.current_account.user.setting_default_content_type
|
||||
store[:system_emoji_font] = object.current_account.user.setting_system_emoji_font
|
||||
store[:crop_images] = object.current_account.user.setting_crop_images
|
||||
store[:visible_reactions] = object.current_account.user.setting_visible_reactions
|
||||
else
|
||||
store[:auto_play_gif] = Setting.auto_play_gif
|
||||
store[:display_media] = Setting.display_media
|
||||
|
|
|
@ -78,6 +78,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
translation: {
|
||||
enabled: TranslationService.configured?,
|
||||
},
|
||||
|
||||
reactions: {
|
||||
max_reactions: StatusReactionValidator::LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -12,7 +12,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
|
||||
|
|
|
@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
has_many :ordered_mentions, key: :mentions
|
||||
has_many :tags
|
||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||
has_many :reactions, serializer: REST::ReactionSerializer
|
||||
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
|
@ -146,6 +147,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
object.active_mentions.to_a.sort_by(&:id)
|
||||
end
|
||||
|
||||
def reactions
|
||||
object.reactions(current_user&.account)
|
||||
end
|
||||
|
||||
class ApplicationSerializer < ActiveModel::Serializer
|
||||
attributes :name, :website
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
27
app/services/react_service.rb
Normal file
27
app/services/react_service.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ReactService < BaseService
|
||||
include Authorization
|
||||
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 reaction unless reaction.nil?
|
||||
|
||||
reaction = StatusReaction.create!(account: account, status: status, name: name, custom_emoji: custom_emoji)
|
||||
|
||||
json = Oj.dump(serialize_payload(reaction, ActivityPub::EmojiReactionSerializer))
|
||||
if status.account.local?
|
||||
NotifyService.new.call(status.account, :reaction, reaction)
|
||||
ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
|
||||
else
|
||||
ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
|
||||
end
|
||||
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
|
||||
reaction
|
||||
end
|
||||
end
|
23
app/services/unreact_service.rb
Normal file
23
app/services/unreact_service.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# 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!
|
||||
|
||||
json = Oj.dump(serialize_payload(reaction, ActivityPub::UndoEmojiReactionSerializer))
|
||||
if status.account.local?
|
||||
ActivityPub::RawDistributionWorker.perform_async(json, status.account.id)
|
||||
else
|
||||
ActivityPub::DeliveryWorker.perform_async(json, reaction.account_id, status.account.inbox_url)
|
||||
end
|
||||
|
||||
reaction
|
||||
end
|
||||
end
|
|
@ -9,7 +9,7 @@ class PollValidator < ActiveModel::Validator
|
|||
def validate(poll)
|
||||
current_time = Time.now.utc
|
||||
|
||||
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1
|
||||
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 0
|
||||
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS
|
||||
poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS }
|
||||
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size
|
||||
|
|
24
app/validators/status_reaction_validator.rb
Normal file
24
app/validators/status_reaction_validator.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
# 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? && limit_reached?(reaction)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unicode_emoji?(name)
|
||||
SUPPORTED_EMOJIS.include?(name)
|
||||
end
|
||||
|
||||
def limit_reached?(reaction)
|
||||
reaction.status.status_reactions.where(status: reaction.status, account: reaction.account).count >= LIMIT
|
||||
end
|
||||
end
|
|
@ -43,7 +43,8 @@
|
|||
= render partial: 'layouts/theme', object: @core
|
||||
= render partial: 'layouts/theme', object: @theme
|
||||
|
||||
= stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
|
||||
- if Setting.custom_css.present?
|
||||
= stylesheet_link_tag custom_css_path, skip_pipeline: true, host: root_url, media: 'all'
|
||||
|
||||
%body{ class: body_classes }
|
||||
= content_for?(:content) ? yield(:content) : yield
|
||||
|
|
|
@ -38,6 +38,9 @@
|
|||
.fields-group
|
||||
= f.input :setting_crop_images, as: :boolean, wrapper: :with_label
|
||||
|
||||
.fields-group.fields-row__column.fields-row__column-6
|
||||
= f.input :setting_visible_reactions, wrapper: :with_label, input_html: { type: 'number', min: '0', data: { default: '6' } }, hint: false
|
||||
|
||||
%h4= t 'appearance.discovery'
|
||||
|
||||
.fields-group
|
||||
|
|
|
@ -138,7 +138,7 @@ Rails.application.configure do
|
|||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-XSS-Protection' => '0',
|
||||
'Permissions-Policy' => 'interest-cohort=()',
|
||||
'X-Clacks-Overhead' => 'GNU Natalie Nguyen',
|
||||
'X-Clacks-Overhead' => 'GNU Anna Harren',
|
||||
'Referrer-Policy' => 'same-origin',
|
||||
}
|
||||
|
||||
|
|
|
@ -40,3 +40,8 @@ de:
|
|||
use_this: Benutze das
|
||||
settings:
|
||||
flavours: Varianten
|
||||
notification_mailer:
|
||||
reaction:
|
||||
body: "%{name} hat auf deinen Beitrag reagiert:"
|
||||
subject: "%{name} hat auf deinen Beitrag reagiert"
|
||||
title: Neue Reaktion
|
||||
|
|
|
@ -40,3 +40,8 @@ en:
|
|||
use_this: Use this
|
||||
settings:
|
||||
flavours: Flavours
|
||||
notification_mailer:
|
||||
reaction:
|
||||
body: "%{name} reacted to your post:"
|
||||
subject: "%{name} reacted to your post"
|
||||
title: New reaction
|
||||
|
|
|
@ -40,3 +40,8 @@ fr:
|
|||
use_this: Utiliser ceci
|
||||
settings:
|
||||
flavours: Thèmes
|
||||
notification_mailer:
|
||||
reaction:
|
||||
body: "%{name} a réagi·e à votre message:"
|
||||
subject: "%{name} a réagi·e à votre message"
|
||||
title: Nouvelle réaction
|
||||
|
|
|
@ -21,6 +21,7 @@ de:
|
|||
setting_hide_followers_count: Anzahl der Follower verbergen
|
||||
setting_skin: Skin
|
||||
setting_system_emoji_font: Systemschriftart für Emojis verwenden (nur für Glitch-Variante)
|
||||
setting_visible_reactions: Anzahl der sichtbaren Emoji-Reaktionen
|
||||
notification_emails:
|
||||
trending_link: Neuer angesagter Link muss überprüft werden
|
||||
trending_status: Neuer angesagter Post muss überprüft werden
|
||||
|
|
|
@ -21,6 +21,7 @@ en:
|
|||
setting_hide_followers_count: Hide your followers count
|
||||
setting_skin: Skin
|
||||
setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
|
||||
setting_visible_reactions: Number of visible emoji reactions
|
||||
notification_emails:
|
||||
trending_link: New trending link requires review
|
||||
trending_status: New trending post requires review
|
||||
|
|
|
@ -21,7 +21,9 @@ fr:
|
|||
setting_hide_followers_count: Cacher votre nombre d'abonné·e·s
|
||||
setting_skin: Thème
|
||||
setting_system_emoji_font: Utiliser la police par défaut du système pour les émojis (s'applique uniquement au mode Glitch)
|
||||
setting_visible_reactions: Nombre de réactions emoji visibles
|
||||
notification_emails:
|
||||
trending_link: Un nouveau lien en tendances nécessite un examen
|
||||
trending_status: Un nouveau post en tendances nécessite un examen
|
||||
trending_tag: Un nouveau tag en tendances nécessite un examen
|
||||
setting_visible_reactions: Nombre de réactions emoji visibles
|
||||
|
|
|
@ -1344,6 +1344,10 @@ de:
|
|||
title: Neue Erwähnung
|
||||
poll:
|
||||
subject: Eine Umfrage von %{name} ist beendet
|
||||
reaction:
|
||||
body: "%{name} hat auf deinen Beitrag reagiert:"
|
||||
subject: "%{name} hat auf deinen Beitrag reagiert"
|
||||
title: Neue Reaktion
|
||||
reblog:
|
||||
body: 'Deinen Beitrag hat %{name} geteilt:'
|
||||
subject: "%{name} hat deinen Beitrag geteilt"
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue