@@ -192,6 +206,8 @@ const AutosuggestTextarea = React.createClass({
onBlur={this.onBlur}
onDragEnter={this.onDragEnter}
onDragExit={this.onDragExit}
+ onPaste={this.onPaste}
+ style={style}
/>
0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
diff --git a/app/assets/javascripts/components/components/column_back_button.jsx b/app/assets/javascripts/components/components/column_back_button.jsx
index 6abf11239b..6b5ffee53f 100644
--- a/app/assets/javascripts/components/components/column_back_button.jsx
+++ b/app/assets/javascripts/components/components/column_back_button.jsx
@@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
mixins: [PureRenderMixin],
handleClick () {
- this.context.router.goBack();
+ if (window.history && window.history.length == 1) this.context.router.push("/");
+ else this.context.router.goBack();
},
render () {
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index 0a8492b562..2b42eaa601 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -10,12 +10,44 @@ const DropdownMenu = React.createClass({
direction: React.PropTypes.string
},
+ getDefaultProps () {
+ return {
+ direction: 'left'
+ };
+ },
+
mixins: [PureRenderMixin],
setRef (c) {
this.dropdown = c;
},
+ handleClick (i, e) {
+ const { action } = this.props.items[i];
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ this.dropdown.hide();
+ }
+ },
+
+ renderItem (item, i) {
+ if (item === null) {
+ return
;
+ }
+
+ const { text, action, href = '#' } = item;
+
+ return (
+
+
+ {text}
+
+
+ );
+ },
+
render () {
const { icon, items, size, direction } = this.props;
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
@@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({
diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx
new file mode 100644
index 0000000000..66e5dee16d
--- /dev/null
+++ b/app/assets/javascripts/components/components/extended_video_player.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ExtendedVideoPlayer = React.createClass({
+
+ propTypes: {
+ src: React.PropTypes.string.isRequired
+ },
+
+ mixins: [PureRenderMixin],
+
+ render () {
+ return (
+
+
+
+ );
+ },
+
+});
+
+export default ExtendedVideoPlayer;
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index b0e397e80f..72b5e977f6 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
@@ -43,6 +44,141 @@ const spoilerButtonStyle = {
zIndex: '100'
};
+const itemStyle = {
+ boxSizing: 'border-box',
+ position: 'relative',
+ float: 'left',
+ border: 'none',
+ display: 'block'
+};
+
+const thumbStyle = {
+ display: 'block',
+ width: '100%',
+ height: '100%',
+ textDecoration: 'none',
+ backgroundSize: 'cover',
+ cursor: 'zoom-in'
+};
+
+const gifvThumbStyle = {
+ position: 'relative',
+ zIndex: '1',
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ cursor: 'zoom-in'
+};
+
+const Item = React.createClass({
+
+ propTypes: {
+ attachment: ImmutablePropTypes.map.isRequired,
+ index: React.PropTypes.number.isRequired,
+ size: React.PropTypes.number.isRequired,
+ onClick: React.PropTypes.func.isRequired
+ },
+
+ mixins: [PureRenderMixin],
+
+ handleClick (e) {
+ const { index, onClick } = this.props;
+
+ if (e.button === 0) {
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ },
+
+ render () {
+ const { attachment, index, size } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'image') {
+ thumbnail = (
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ thumbnail = (
+
+ );
+ }
+
+ return (
+
+ {thumbnail}
+
+ );
+ }
+
+});
+
const MediaGallery = React.createClass({
getInitialState () {
@@ -61,17 +197,12 @@ const MediaGallery = React.createClass({
mixins: [PureRenderMixin],
- handleClick (index, e) {
- if (e.button === 0) {
- e.preventDefault();
- this.props.onOpenMedia(this.props.media, index);
- }
-
- e.stopPropagation();
+ handleOpen (e) {
+ this.setState({ visible: !this.state.visible });
},
- handleOpen () {
- this.setState({ visible: !this.state.visible });
+ handleClick (index) {
+ this.props.onOpenMedia(this.props.media, index);
},
render () {
@@ -80,87 +211,31 @@ const MediaGallery = React.createClass({
let children;
if (!this.state.visible) {
+ let warning;
+
if (sensitive) {
- children = (
-
-
-
-
- );
+ warning =
;
} else {
- children = (
-
-
-
-
- );
+ warning =
;
}
+
+ children = (
+
+ {warning}
+
+
+ );
} else {
const size = media.take(4).size;
-
- children = media.take(4).map((attachment, i) => {
- let width = 50;
- let height = 100;
- let top = 'auto';
- let left = 'auto';
- let bottom = 'auto';
- let right = 'auto';
-
- if (size === 1) {
- width = 100;
- }
-
- if (size === 4 || (size === 3 && i > 0)) {
- height = 50;
- }
-
- if (size === 2) {
- if (i === 0) {
- right = '2px';
- } else {
- left = '2px';
- }
- } else if (size === 3) {
- if (i === 0) {
- right = '2px';
- } else if (i > 0) {
- left = '2px';
- }
-
- if (i === 1) {
- bottom = '2px';
- } else if (i > 1) {
- top = '2px';
- }
- } else if (size === 4) {
- if (i === 0 || i === 2) {
- right = '2px';
- }
-
- if (i === 1 || i === 3) {
- left = '2px';
- }
-
- if (i < 2) {
- bottom = '2px';
- } else {
- top = '2px';
- }
- }
-
- return (
-
- );
- });
+ children = media.take(4).map((attachment, i) =>
);
}
return (
-
);
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index 35c458b5ee..469506f2fe 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -6,13 +6,13 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
- mention: { id: 'status.mention', defaultMessage: 'Mention' },
- block: { id: 'account.block', defaultMessage: 'Block' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- open: { id: 'status.open', defaultMessage: 'Expand' },
- report: { id: 'status.report', defaultMessage: 'Report' }
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' }
});
const StatusActionBar = React.createClass({
@@ -74,13 +74,15 @@ const StatusActionBar = React.createClass({
let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+ menu.push(null);
if (status.getIn(['account', 'id']) === me) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
- menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
- menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
}
return (
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index 43bbb95820..6c25afdea5 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../emoji';
+import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl';
import Permalink from './permalink';
@@ -92,6 +93,11 @@ const StatusContent = React.createClass({
const content = { __html: emojify(status.get('content')) };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+ const directionStyle = { direction: 'ltr' };
+
+ if (isRtl(status.get('content'))) {
+ directionStyle.direction = 'rtl';
+ }
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@@ -116,14 +122,14 @@ const StatusContent = React.createClass({
{mentionsPlaceholder}
-
+
);
} else {
return (
@@ -117,6 +155,16 @@ const VideoPlayer = React.createClass({
);
+ let muteButton = '';
+
+ if (this.state.hasAudio) {
+ muteButton = (
+
+
+
+ );
+ }
+
if (!this.state.visible) {
if (sensitive) {
return (
@@ -128,7 +176,7 @@ const VideoPlayer = React.createClass({
);
} else {
return (
-
+
{spoilerButton}
@@ -137,7 +185,7 @@ const VideoPlayer = React.createClass({
}
}
- if (this.state.preview) {
+ if (this.state.preview && !autoplay) {
return (
{spoilerButton}
@@ -149,8 +197,8 @@ const VideoPlayer = React.createClass({
return (
{spoilerButton}
-
-
+ {muteButton}
+
);
}
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 889c0ac4c0..3c30be7152 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -5,7 +5,9 @@ import {
followAccount,
unfollowAccount,
blockAccount,
- unblockAccount
+ unblockAccount,
+ muteAccount,
+ unmuteAccount,
} from '../actions/accounts';
const makeMapStateToProps = () => {
@@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
} else {
dispatch(blockAccount(account.get('id')));
}
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(muteAccount(account.get('id')));
+ }
}
});
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 81265bc50f..e7543bc397 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -11,7 +11,10 @@ import {
unreblog,
unfavourite
} from '../actions/interactions';
-import { blockAccount } from '../actions/accounts';
+import {
+ blockAccount,
+ muteAccount
+} from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { initReport } from '../actions/reports';
import { openMedia } from '../actions/modal';
@@ -69,7 +72,11 @@ const mapDispatchToProps = (dispatch) => ({
onReport (status) {
dispatch(initReport(status.get('account'), status));
- }
+ },
+
+ onMute (account) {
+ dispatch(muteAccount(account.get('id')));
+ },
});
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx
index a2ab8172b6..80a32d3e28 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -5,14 +5,16 @@ import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = defineMessages({
- mention: { id: 'account.mention', defaultMessage: 'Mention' },
+ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
- unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
- block: { id: 'account.block', defaultMessage: 'Block' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
- block: { id: 'account.block', defaultMessage: 'Block' },
- report: { id: 'account.report', defaultMessage: 'Report' }
+ report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+ disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
});
const outerDropdownStyle = {
@@ -35,6 +37,7 @@ const ActionBar = React.createClass({
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func.isRequired,
+ onMute: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
@@ -44,21 +47,31 @@ const ActionBar = React.createClass({
const { account, me, intl } = this.props;
let menu = [];
+ let extraInfo = '';
- menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+ menu.push(null);
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
- } else if (account.getIn(['relationship', 'blocking'])) {
- menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
- } else if (account.getIn(['relationship', 'following'])) {
- menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
} else {
- menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
+ if (account.getIn(['relationship', 'muting'])) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+ }
+
+ if (account.getIn(['relationship', 'blocking'])) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
}
- if (account.get('id') !== me) {
- menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
+ if (account.get('acct') !== account.get('username')) {
+ extraInfo =
*;
}
return (
@@ -70,17 +83,17 @@ const ActionBar = React.createClass({
-
+ {extraInfo}
-
+ {extraInfo}
-
+ {extraInfo}
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
index 2dd3ca7b15..99a10562e7 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -15,7 +15,8 @@ const Header = React.createClass({
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
- onReport: React.PropTypes.func.isRequired
+ onReport: React.PropTypes.func.isRequired,
+ onMute: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -37,6 +38,10 @@ const Header = React.createClass({
this.context.router.push('/report');
},
+ handleMute() {
+ this.props.onMute(this.props.account);
+ },
+
render () {
const { account, me } = this.props;
@@ -58,6 +63,7 @@ const Header = React.createClass({
onBlock={this.handleBlock}
onMention={this.handleMention}
onReport={this.handleReport}
+ onMute={this.handleMute}
/>
);
diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
index e4ce905fe7..8472d25a5f 100644
--- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
@@ -5,7 +5,9 @@ import {
followAccount,
unfollowAccount,
blockAccount,
- unblockAccount
+ unblockAccount,
+ muteAccount,
+ unmuteAccount
} from '../../../actions/accounts';
import { mentionCompose } from '../../../actions/compose';
import { initReport } from '../../../actions/reports';
@@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({
onReport (account) {
dispatch(initReport(account));
+ },
+
+ onMute (account) {
+ if (account.getIn(['relationship', 'muting'])) {
+ dispatch(unmuteAccount(account.get('id')));
+ } else {
+ dispatch(muteAccount(account.get('id')));
+ }
}
});
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index aa1b8368e9..2cfd7b2fe0 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -20,6 +20,8 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token'])
});
+let subscription;
+
const CommunityTimeline = React.createClass({
propTypes: {
@@ -36,7 +38,11 @@ const CommunityTimeline = React.createClass({
dispatch(refreshTimeline('community'));
- this.subscription = createStream(accessToken, 'public:local', {
+ if (typeof subscription !== 'undefined') {
+ return;
+ }
+
+ subscription = createStream(accessToken, 'public:local', {
received (data) {
switch(data.event) {
@@ -53,10 +59,10 @@ const CommunityTimeline = React.createClass({
},
componentWillUnmount () {
- if (typeof this.subscription !== 'undefined') {
- this.subscription.close();
- this.subscription = null;
- }
+ // if (typeof subscription !== 'undefined') {
+ // subscription.close();
+ // subscription = null;
+ // }
},
render () {
diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
index f0c1b7c8d8..e6b6753544 100644
--- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx
+++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
@@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
mixins: [PureRenderMixin],
render () {
- const diff = this.props.max - this.props.text.length;
+ const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length;
return (
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 31ae8e0347..047c974f29 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -15,6 +15,7 @@ import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
import PrivateToggleContainer from '../containers/private_toggle_container';
import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -47,6 +48,8 @@ const ComposeForm = React.createClass({
onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired,
onChangeSpoilerText: React.PropTypes.func.isRequired,
+ onPaste: React.PropTypes.func.isRequired,
+ onPickEmoji: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -75,6 +78,7 @@ const ComposeForm = React.createClass({
},
onSuggestionSelected (tokenStart, token, value) {
+ this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value);
},
@@ -87,8 +91,18 @@ const ComposeForm = React.createClass({
// If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
- const selectionEnd = this.props.text.length;
- const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd;
+ let selectionEnd, selectionStart;
+
+ if (this.props.preselectDate !== prevProps.preselectDate) {
+ selectionEnd = this.props.text.length;
+ selectionStart = this.props.text.search(/\s/) + 1;
+ } else if (typeof this._restoreCaret === 'number') {
+ selectionStart = this._restoreCaret;
+ selectionEnd = this._restoreCaret;
+ } else {
+ selectionEnd = this.props.text.length;
+ selectionStart = selectionEnd;
+ }
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
@@ -99,8 +113,14 @@ const ComposeForm = React.createClass({
this.autosuggestTextarea = c;
},
+ handleEmojiPick (data) {
+ const position = this.autosuggestTextarea.textarea.selectionStart;
+ this._restoreCaret = position + data.shortname.length + 1;
+ this.props.onPickEmoji(position, data);
+ },
+
render () {
- const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
+ const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
const disabled = this.props.is_submitting || this.props.is_uploading;
let publishText = '';
@@ -149,12 +169,16 @@ const ComposeForm = React.createClass({
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
+ onPaste={onPaste}
/>
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 0000000000..3a454a5fbd
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,52 @@
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ emoji: { id: 'emoji_button.label', defaultMessage: 'Emoji' }
+});
+
+const settings = {
+ imageType: 'png',
+ sprites: false,
+ imagePathPNG: '/emoji/'
+};
+
+const EmojiPickerDropdown = React.createClass({
+
+ propTypes: {
+ intl: React.PropTypes.object.isRequired,
+ onPickEmoji: React.PropTypes.func.isRequired
+ },
+
+ mixins: [PureRenderMixin],
+
+ setRef (c) {
+ this.dropdown = c;
+ },
+
+ handleChange (data) {
+ this.dropdown.hide();
+ this.props.onPickEmoji(data);
+ },
+
+ render () {
+ const { intl } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+});
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 53129af6e3..a67adbdd62 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -1,5 +1,6 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
import { createSelector } from 'reselect';
import {
changeCompose,
@@ -8,6 +9,7 @@ import {
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
+ insertEmojiCompose
} from '../../../actions/compose';
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
@@ -65,6 +67,14 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeComposeSpoilerText(checked));
},
+ onPaste (files) {
+ dispatch(uploadCompose(files));
+ },
+
+ onPickEmoji (position, data) {
+ dispatch(insertEmojiCompose(position, data));
+ },
+
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index f8433b8f43..48b4a6b8e2 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -45,8 +45,7 @@ const GettingStarted = ({ intl, me }) => {
-
-
tootsuite/mastodon }} />
+
tootsuite/mastodon }} />
diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
index 0b7c737c6b..d75149a0e3 100644
--- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
@@ -4,7 +4,8 @@ const iconStyle = {
position: 'absolute',
right: '48px',
top: '0',
- cursor: 'pointer'
+ cursor: 'pointer',
+ zIndex: '2'
};
const ClearColumnButton = ({ onClick }) => (
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 0da3544f6b..74b914ffd2 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
import ClearColumnButton from './components/clear_column_button';
const messages = defineMessages({
- title: { id: 'column.notifications', defaultMessage: 'Notifications' }
+ title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+ confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' }
});
const getNotifications = createSelector([
@@ -72,7 +73,9 @@ const Notifications = React.createClass({
},
handleClear () {
- this.props.dispatch(clearNotifications());
+ if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
+ this.props.dispatch(clearNotifications());
+ }
},
setRef (c) {
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index ce4eacc92f..b2342abbdd 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -20,6 +20,8 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token'])
});
+let subscription;
+
const PublicTimeline = React.createClass({
propTypes: {
@@ -36,7 +38,11 @@ const PublicTimeline = React.createClass({
dispatch(refreshTimeline('public'));
- this.subscription = createStream(accessToken, 'public', {
+ if (typeof subscription !== 'undefined') {
+ return;
+ }
+
+ subscription = createStream(accessToken, 'public', {
received (data) {
switch(data.event) {
@@ -53,10 +59,10 @@ const PublicTimeline = React.createClass({
},
componentWillUnmount () {
- if (typeof this.subscription !== 'undefined') {
- this.subscription.close();
- this.subscription = null;
- }
+ // if (typeof subscription !== 'undefined') {
+ // subscription.close();
+ // subscription = null;
+ // }
},
render () {
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index cc4d5cca42..2acf942743 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -6,11 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
- mention: { id: 'status.mention', defaultMessage: 'Mention' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
- report: { id: 'status.report', defaultMessage: 'Report' }
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' }
});
const ActionBar = React.createClass({
@@ -66,8 +66,9 @@ const ActionBar = React.createClass({
if (me === status.getIn(['account', 'id'])) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
- menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
}
return (
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index 8a7c0c5d5e..caa46ff3c5 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- media =
;
+ media =
;
} else {
media =
;
}
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index d8301b20f8..e3c4281b93 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
const mapStateToProps = state => ({
media: state.getIn(['modal', 'media']),
@@ -131,27 +132,34 @@ const Modal = React.createClass({
return null;
}
- const url = media.get(index).get('url');
+ const attachment = media.get(index);
+ const url = attachment.get('url');
- let leftNav, rightNav;
+ let leftNav, rightNav, content;
- leftNav = rightNav = '';
+ leftNav = rightNav = content = '';
if (media.size > 1) {
leftNav =
;
rightNav =
;
}
- return (
-
- {leftNav}
-
+ if (attachment.get('type') === 'image') {
+ content = (
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ content = ;
+ }
+ return (
+
+ {leftNav}
+ {content}
{rightNav}
);
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
index eaa6221e41..992e63727a 100644
--- a/app/assets/javascripts/components/is_mobile.jsx
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
export function isMobile(width) {
return width <= LAYOUT_BREAKPOINT;
};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+export function isIOS() {
+ return iOS;
+};
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index f1d6a6dbcc..3131dca1ac 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -2,7 +2,7 @@ const en = {
"column_back_button.label": "Back",
"lightbox.close": "Close",
"loading_indicator.label": "Loading...",
- "status.mention": "Mention",
+ "status.mention": "Mention @{name}",
"status.delete": "Delete",
"status.reply": "Reply",
"status.reblog": "Boost",
@@ -11,11 +11,11 @@ const en = {
"status.sensitive_warning": "Sensitive content",
"status.sensitive_toggle": "Click to view",
"video_player.toggle_sound": "Toggle sound",
- "account.mention": "Mention",
+ "account.mention": "Mention @{name}",
"account.edit_profile": "Edit profile",
- "account.unblock": "Unblock",
+ "account.unblock": "Unblock @{name}",
"account.unfollow": "Unfollow",
- "account.block": "Block",
+ "account.block": "Block @{name}",
"account.follow": "Follow",
"account.posts": "Posts",
"account.follows": "Follows",
@@ -25,16 +25,15 @@ const en = {
"getting_started.heading": "Getting started",
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
- "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
- "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
+ "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.",
"column.home": "Home",
- "column.community": "Local",
- "column.public": "Whole Known Network",
+ "column.community": "Local timeline",
+ "column.public": "Federated timeline",
"column.notifications": "Notifications",
"tabs_bar.compose": "Compose",
"tabs_bar.home": "Home",
"tabs_bar.mentions": "Mentions",
- "tabs_bar.public": "Whole Known Network",
+ "tabs_bar.public": "Federated timeline",
"tabs_bar.notifications": "Notifications",
"compose_form.placeholder": "What is on your mind?",
"compose_form.publish": "Toot",
@@ -46,7 +45,7 @@ const en = {
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences",
"navigation_bar.community_timeline": "Local timeline",
- "navigation_bar.public_timeline": "Whole Known Network",
+ "navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancel",
"search.placeholder": "Search",
diff --git a/app/assets/javascripts/components/middleware/sounds.jsx b/app/assets/javascripts/components/middleware/sounds.jsx
new file mode 100644
index 0000000000..200efa3d79
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/sounds.jsx
@@ -0,0 +1,22 @@
+const play = audio => {
+ if (!audio.paused) {
+ audio.pause();
+ audio.fastSeek(0);
+ }
+
+ audio.play();
+};
+
+export default function soundsMiddleware() {
+ const soundCache = {
+ boop: new Audio(['/sounds/boop.mp3'])
+ };
+
+ return ({ dispatch }) => next => (action) => {
+ if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+ play(soundCache[action.meta.sound]);
+ }
+
+ return next(action);
+ };
+};
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index dead5fd770..b0001351f9 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -20,7 +20,8 @@ import {
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
- COMPOSE_LISTABILITY_CHANGE
+ COMPOSE_LISTABILITY_CHANGE,
+ COMPOSE_EMOJI_INSERT
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
@@ -105,6 +106,15 @@ const insertSuggestion = (state, position, token, completion) => {
});
};
+const insertEmoji = (state, position, emojiData) => {
+ const emoji = emojiData.shortname;
+
+ return state.withMutations(map => {
+ map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+ map.set('focusDate', new Date());
+ });
+};
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@@ -177,6 +187,8 @@ export default function compose(state = initialState, action) {
} else {
return state;
}
+ case COMPOSE_EMOJI_INSERT:
+ return insertEmoji(state, action.position, action.emoji);
default:
return state;
}
diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx
index e4af1f0282..591f8034be 100644
--- a/app/assets/javascripts/components/reducers/relationships.jsx
+++ b/app/assets/javascripts/components/reducers/relationships.jsx
@@ -3,6 +3,8 @@ import {
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+ ACCOUNT_UNMUTE_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS
} from '../actions/accounts';
import Immutable from 'immutable';
@@ -25,6 +27,8 @@ export default function relationships(state = initialState, action) {
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ case ACCOUNT_UNMUTE_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 6472ac6a01..c67d054235 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -22,7 +22,8 @@ import {
ACCOUNT_TIMELINE_EXPAND_REQUEST,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_FAIL,
- ACCOUNT_BLOCK_SUCCESS
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS
} from '../actions/accounts';
import {
CONTEXT_FETCH_SUCCESS
@@ -295,6 +296,7 @@ export default function timelines(state = initialState, action) {
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top);
diff --git a/app/assets/javascripts/components/rtl.jsx b/app/assets/javascripts/components/rtl.jsx
new file mode 100644
index 0000000000..8f14bb3382
--- /dev/null
+++ b/app/assets/javascripts/components/rtl.jsx
@@ -0,0 +1,27 @@
+// U+0590 to U+05FF - Hebrew
+// U+0600 to U+06FF - Arabic
+// U+0700 to U+074F - Syriac
+// U+0750 to U+077F - Arabic Supplement
+// U+0780 to U+07BF - Thaana
+// U+07C0 to U+07FF - N'Ko
+// U+0800 to U+083F - Samaritan
+// U+08A0 to U+08FF - Arabic Extended-A
+// U+FB1D to U+FB4F - Hebrew presentation forms
+// U+FB50 to U+FDFF - Arabic presentation forms A
+// U+FE70 to U+FEFF - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+ if (text.length === 0) {
+ return false;
+ }
+
+ const matches = text.match(rtlChars);
+
+ if (!matches) {
+ return false;
+ }
+
+ return matches.length / text.trim().length > 0.3;
+};
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index ad0427b52f..a92d756f54 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
import appReducer from '../reducers';
import loadingBarMiddleware from '../middleware/loading_bar';
import errorsMiddleware from '../middleware/errors';
-import soundsMiddleware from 'redux-sounds';
-import Howler from 'howler';
+import soundsMiddleware from '../middleware/sounds';
import Immutable from 'immutable';
-Howler.mobileAutoEnable = false;
-
-const soundsData = {
- boop: '/sounds/boop.mp3'
-};
-
export default function configureStore() {
return createStore(appReducer, compose(applyMiddleware(
thunk,
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
errorsMiddleware(),
- soundsMiddleware(soundsData)
+ soundsMiddleware()
), window.devToolsExtension ? window.devToolsExtension() : f => f));
};
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 5fc67d9c1c..4b1e86aca4 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,3 +1,5 @@
+@import 'variables';
+
.button {
background-color: darken($color4, 3%);
font-family: inherit;
@@ -59,6 +61,14 @@
&.active {
color: $color4;
}
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.dropdown--active .icon-button {
+ color: $color4;
}
.invisible {
@@ -387,6 +397,10 @@ a.status__content__spoiler-link {
font-weight: 500;
color: $color5;
}
+
+ abbr {
+ color: lighten($color1, 26%);
+ }
}
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
@@ -516,6 +530,12 @@ a.status__content__spoiler-link {
position: absolute;
}
+.dropdown__sep {
+ border-bottom: 1px solid darken($color2, 8%);
+ margin: 5px 7px 6px;
+ padding-top: 1px;
+}
+
.dropdown--active .dropdown__content {
display: block;
z-index: 9999;
@@ -533,23 +553,40 @@ a.status__content__spoiler-link {
left: 8px;
}
- ul {
+ & > ul {
list-style: none;
background: $color2;
padding: 4px 0;
border-radius: 4px;
box-shadow: 0 0 15px rgba($color8, 0.4);
- min-width: 100px;
+ min-width: 140px;
+ position: relative;
+ left: -10px;
}
- a {
+ &.dropdown__left {
+ & > ul {
+ left: -98px;
+ }
+ }
+
+ & > ul > li > a {
font-size: 13px;
+ line-height: 18px;
display: block;
- padding: 6px 16px;
- width: 100px;
+ padding: 4px 14px;
+ box-sizing: border-box;
+ width: 140px;
text-decoration: none;
background: $color2;
color: $color1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus {
+ outline: none;
+ }
&:hover {
background: $color4;
@@ -983,15 +1020,6 @@ a.status__content__spoiler-link {
}
}
-.dropdown__content.dropdown__left {
- transform: translateX(-108px);
-
- &::before {
- right: 8px !important;
- left: initial !important;
- }
-}
-
.setting-text {
color: $color3;
background: transparent;
@@ -1074,8 +1102,10 @@ button.active i.fa-retweet {
text-align: center;
font-size: 16px;
font-weight: 500;
- color: lighten($color1, 26%);
- padding-top: 120px;
+ color: lighten($color1, 16%);
+ padding-top: 210px;
+ background: image-url('mastodon-not-found.png') no-repeat center -50px;
+ cursor: default;
}
.column-header {
@@ -1230,3 +1260,164 @@ button.active i.fa-retweet {
z-index: 1;
background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
}
+
+.emoji-dialog {
+ width: 280px;
+ height: 220px;
+ background: $color2;
+ box-sizing: border-box;
+ border-radius: 2px;
+ overflow: hidden;
+ position: relative;
+ box-shadow: 0 0 15px rgba($color8, 0.4);
+
+ .emojione {
+ margin: 0;
+ }
+
+ .emoji-dialog-header {
+ padding: 0 10px;
+ background-color: $color3;
+
+ ul {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+
+ li {
+ display: inline-block;
+ box-sizing: border-box;
+ height: 42px;
+ padding: 9px 5px;
+ cursor: pointer;
+
+ img, svg {
+ width: 22px;
+ height: 22px;
+ filter: grayscale(100%);
+ }
+
+ &.active {
+ background: lighten($color3, 6%);
+
+ img, svg {
+ filter: grayscale(0);
+ }
+ }
+ }
+ }
+
+ .emoji-row {
+ box-sizing: border-box;
+ overflow-y: hidden;
+ padding-left: 10px;
+
+ .emoji {
+ display: inline-block;
+ padding: 5px;
+ border-radius: 4px;
+ }
+ }
+
+ .emoji-category-header {
+ box-sizing: border-box;
+ overflow-y: hidden;
+ padding: 8px 16px 0;
+ display: table;
+
+ > * {
+ display: table-cell;
+ vertical-align: middle;
+ }
+ }
+
+ .emoji-category-title {
+ font-size: 14px;
+ font-family: sans-serif;
+ font-weight: normal;
+ color: $color1;
+ cursor: default;
+ }
+
+ .emoji-category-heading-decoration {
+ text-align: right;
+ }
+
+ .modifiers {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ vertical-align: middle;
+ white-space: nowrap;
+ margin-top: 4px;
+
+ li {
+ display: inline-block;
+ padding: 0 2px;
+
+ &:last-of-type {
+ padding-right: 0;
+ }
+ }
+
+ .modifier {
+ display: inline-block;
+ border-radius: 10px;
+ width: 15px;
+ height: 15px;
+ position: relative;
+ cursor: pointer;
+
+ &.active:after {
+ content: "";
+ display: block;
+ position: absolute;
+ width: 7px;
+ height: 7px;
+ border-radius: 10px;
+ border: 2px solid $color1;
+ top: 2px;
+ left: 2px;
+ }
+ }
+ }
+
+ .emoji-search-wrapper {
+ padding: 6px 16px;
+ }
+
+ .emoji-search {
+ font-size: 12px;
+ padding: 6px 4px;
+ width: 100%;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ }
+
+ .emoji-categories-wrapper {
+ position: absolute;
+ top: 42px;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ }
+
+ .emoji-search-wrapper + .emoji-categories-wrapper {
+ top: 83px;
+ }
+
+ .emoji-row .emoji:hover {
+ background: lighten($color2, 3%);
+ }
+
+ .emoji {
+ width: 22px;
+ height: 22px;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 3b2e88f6dc..b9a9a1da3e 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -104,8 +104,12 @@
overflow: hidden;
width: 100%;
box-sizing: border-box;
- height: 110px;
- display: flex;
+ position: relative;
+
+ .status__attachments__inner {
+ display: flex;
+ height: 214px;
+ }
}
}
@@ -184,8 +188,12 @@
overflow: hidden;
width: 100%;
box-sizing: border-box;
- height: 300px;
- display: flex;
+ position: relative;
+
+ .status__attachments__inner {
+ display: flex;
+ height: 360px;
+ }
}
.video-player {
@@ -231,11 +239,19 @@
text-decoration: none;
cursor: zoom-in;
}
+
+ video {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ top: 50%;
+ transform: translateY(-50%);
+ }
}
.video-item {
- max-width: 196px;
-
a {
cursor: pointer;
}
@@ -258,6 +274,9 @@
width: 100%;
height: 100%;
cursor: pointer;
+ position: absolute;
+ top: 0;
+ left: 0;
display: flex;
align-items: center;
justify-content: center;
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 94dba1d038..9c84e0a1b4 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class Api::V1::AccountsController < ApiController
- before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
- before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
+ before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
+ before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
before_action :require_user!, except: [:show, :following, :followers, :statuses]
before_action :set_account, except: [:verify_credentials, :suggestions, :search]
@@ -47,10 +47,13 @@ class Api::V1::AccountsController < ApiController
def statuses
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+ @statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media]
+ @statuses = @statuses.without_replies if params[:exclude_replies]
@statuses = cache_collection(@statuses, Status)
set_maps(@statuses)
set_counters_maps(@statuses)
+ set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty?
prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -58,21 +61,6 @@ class Api::V1::AccountsController < ApiController
set_pagination_headers(next_path, prev_path)
end
- def media_statuses
- media_ids = MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')
- @statuses = @account.statuses.where(id: media_ids).permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
- @statuses = cache_collection(@statuses, Status)
-
- set_maps(@statuses)
- set_counters_maps(@statuses)
-
- next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty?
- prev_path = media_statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
-
- set_pagination_headers(next_path, prev_path)
- render action: :statuses
- end
-
def follow
FollowService.new.call(current_user.account, @account.acct)
set_relationship
@@ -86,10 +74,17 @@ class Api::V1::AccountsController < ApiController
@followed_by = { @account.id => false }
@blocking = { @account.id => true }
@requested = { @account.id => false }
+ @muting = { @account.id => current_user.account.muting?(@account.id) }
render action: :relationship
end
+ def mute
+ MuteService.new.call(current_user.account, @account)
+ set_relationship
+ render action: :relationship
+ end
+
def unfollow
UnfollowService.new.call(current_user.account, @account)
set_relationship
@@ -102,6 +97,12 @@ class Api::V1::AccountsController < ApiController
render action: :relationship
end
+ def unmute
+ UnmuteService.new.call(current_user.account, @account)
+ set_relationship
+ render action: :relationship
+ end
+
def relationships
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
@@ -109,6 +110,7 @@ class Api::V1::AccountsController < ApiController
@following = Account.following_map(ids, current_user.account_id)
@followed_by = Account.followed_by_map(ids, current_user.account_id)
@blocking = Account.blocking_map(ids, current_user.account_id)
+ @muting = Account.muting_map(ids, current_user.account_id)
@requested = Account.requested_map(ids, current_user.account_id)
end
@@ -130,6 +132,7 @@ class Api::V1::AccountsController < ApiController
@following = Account.following_map([@account.id], current_user.account_id)
@followed_by = Account.followed_by_map([@account.id], current_user.account_id)
@blocking = Account.blocking_map([@account.id], current_user.account_id)
+ @muting = Account.muting_map([@account.id], current_user.account_id)
@requested = Account.requested_map([@account.id], current_user.account_id)
end
end
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
new file mode 100644
index 0000000000..42a9ed412c
--- /dev/null
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::MutesController < ApiController
+ before_action -> { doorkeeper_authorize! :follow }
+ before_action :require_user!
+
+ respond_to :json
+
+ def index
+ results = Mute.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
+ accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
+ @accounts = results.map { |f| accounts[f.target_account_id] }
+
+ set_account_counters_maps(@accounts)
+
+ next_path = api_v1_mutes_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+ prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty?
+
+ set_pagination_headers(next_path, prev_path)
+ end
+end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index c2002cb796..db16f82e5b 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -79,6 +79,7 @@ class ApiController < ApplicationController
def require_user!
current_resource_owner
+ set_user_activity
rescue ActiveRecord::RecordNotFound
render json: { error: 'This method requires an authenticated user' }, status: 422
end
diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb
index dde7ce8c68..9c896fb098 100644
--- a/app/controllers/concerns/obfuscate_filename.rb
+++ b/app/controllers/concerns/obfuscate_filename.rb
@@ -13,6 +13,10 @@ module ObfuscateFilename
file = params.dig(*path)
return if file.nil?
- file.original_filename = 'media' + File.extname(file.original_filename)
+ file.original_filename = secure_token + File.extname(file.original_filename)
+ end
+
+ def secure_token(length = 16)
+ SecureRandom.hex(length / 2)
end
end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index b7479bf8ca..60400e465f 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController
reblog: user_params[:notification_emails][:reblog] == '1',
favourite: user_params[:notification_emails][:favourite] == '1',
mention: user_params[:notification_emails][:mention] == '1',
+ digest: user_params[:notification_emails][:digest] == '1',
}
current_user.settings['interactions'] = {
@@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController
private
def user_params
- params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
+ params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
end
end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 15601a0796..a26e912a3b 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -37,4 +37,17 @@ module StreamEntriesHelper
def proper_status(status)
status.reblog? ? status.reblog : status
end
+
+ def rtl?(text)
+ return false if text.empty?
+
+ matches = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text)
+
+ return false unless matches
+
+ rtl_size = matches.to_a.reduce(0) { |acc, elem| acc + elem.size }.to_f
+ ltr_size = text.strip.size.to_f
+
+ rtl_size / ltr_size > 0.3
+ end
end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 623a1af035..3a26c5c056 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -22,8 +22,18 @@ class FeedManager
end
def push(timeline_type, account, status)
- redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
- trim(timeline_type, account.id)
+ timeline_key = key(timeline_type, account.id)
+
+ if status.reblog?
+ # If the original status is within 40 statuses from top, do not re-insert it into the feed
+ rank = redis.zrevrank(timeline_key, status.reblog_of_id)
+ return if !rank.nil? && rank < 40
+ redis.zadd(timeline_key, status.id, status.reblog_of_id)
+ else
+ redis.zadd(timeline_key, status.id, status.id)
+ trim(timeline_type, account.id)
+ end
+
broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
end
@@ -85,6 +95,8 @@ class FeedManager
end
def filter_from_home?(status, receiver)
+ return true if receiver.muting?(status.account)
+
should_filter = false
if status.reply? && status.in_reply_to_id.nil?
@@ -95,6 +107,7 @@ class FeedManager
should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
elsif status.reblog? # Filter out a reblog
should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person
+ should_filter ||= receiver.muting?(status.reblog.account) # or muting that person
end
should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index e353c35040..b58952ae0f 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -29,6 +29,11 @@ class Formatter
sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
end
+ def plaintext(status)
+ return status.text if status.local?
+ strip_tags(status.text)
+ end
+
def simplified_format(account)
return reformat(account.note) unless account.local?
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index a1b084682f..bf4c16e438 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
end
end
+
+ def digest(recipient, opts = {})
+ @me = recipient
+ @since = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at
+ @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since)
+ @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count
+
+ return if @notifications.empty?
+
+ I18n.with_locale(@me.user.locale || I18n.default_locale) do
+ mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size)
+ end
+ end
end
diff --git a/app/models/account.rb b/app/models/account.rb
index a93a0668a5..0780789459 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -4,7 +4,7 @@ class Account < ApplicationRecord
include Targetable
include PgSearch
- MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
+ MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
# Local users
@@ -46,6 +46,10 @@ class Account < ApplicationRecord
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
+ # Mute relationships
+ has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
+ has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
+
# Media
has_many :media_attachments, dependent: :destroy
@@ -73,6 +77,10 @@ class Account < ApplicationRecord
block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
end
+ def mute!(other_account)
+ mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
+ end
+
def unfollow!(other_account)
follow = active_relationships.find_by(target_account: other_account)
follow&.destroy
@@ -83,6 +91,11 @@ class Account < ApplicationRecord
block&.destroy
end
+ def unmute!(other_account)
+ mute = mute_relationships.find_by(target_account: other_account)
+ mute&.destroy
+ end
+
def following?(other_account)
following.include?(other_account)
end
@@ -91,6 +104,10 @@ class Account < ApplicationRecord
blocking.include?(other_account)
end
+ def muting?(other_account)
+ muting.include?(other_account)
+ end
+
def requested?(other_account)
follow_requests.where(target_account: other_account).exists?
end
@@ -188,6 +205,10 @@ class Account < ApplicationRecord
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
+ def muting_map(target_account_ids, account_id)
+ follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+ end
+
def requested_map(target_account_ids, account_id)
follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6925f9b0d7..8181902144 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,15 +1,32 @@
# frozen_string_literal: true
class MediaAttachment < ApplicationRecord
+ self.inheritance_column = nil
+
+ enum type: [:image, :gifv, :video]
+
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
+ IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+ VIDEO_STYLES = {
+ small: {
+ convert_options: {
+ output: {
+ vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+ },
+ },
+ format: 'png',
+ time: 0,
+ },
+ }.freeze
+
belongs_to :account, inverse_of: :media_attachments
belongs_to :status, inverse_of: :media_attachments
has_attached_file :file,
- styles: -> (f) { file_styles f },
- processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
+ styles: ->(f) { file_styles f },
+ processors: ->(f) { file_processors f },
convert_options: { all: '-quality 90 -strip' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
validates_attachment_size :file, less_than: 8.megabytes
@@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
self.file = URI.parse(url)
end
- def image?
- IMAGE_MIME_TYPES.include? file_content_type
- end
-
- def video?
- VIDEO_MIME_TYPES.include? file_content_type
- end
-
- def type
- image? ? 'image' : 'video'
- end
-
def to_param
shortcode
end
before_create :set_shortcode
+ before_post_process :set_type
class << self
private
def file_styles(f)
- if f.instance.image?
+ if f.instance.file_content_type == 'image/gif'
{
- original: '1280x1280>',
- small: '400x400>',
- }
- else
- {
- small: {
+ small: IMAGE_STYLES[:small],
+ original: {
+ format: 'mp4',
convert_options: {
output: {
- vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+ 'movflags' => 'faststart',
+ 'pix_fmt' => 'yuv420p',
+ 'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
+ 'vsync' => 'cfr',
+ 'b:v' => '1300K',
+ 'maxrate' => '500K',
+ 'crf' => 6,
},
},
- format: 'png',
- time: 1,
},
}
+ elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
+ IMAGE_STYLES
+ else
+ VIDEO_STYLES
+ end
+ end
+
+ def file_processors(f)
+ if f.file_content_type == 'image/gif'
+ [:gif_transcoder]
+ elsif VIDEO_MIME_TYPES.include? f.file_content_type
+ [:video_transcoder]
+ else
+ [:thumbnail]
end
end
end
@@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
break if MediaAttachment.find_by(shortcode: shortcode).nil?
end
end
+
+ def set_type
+ self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+ end
end
diff --git a/app/models/mute.rb b/app/models/mute.rb
new file mode 100644
index 0000000000..a5b334c859
--- /dev/null
+++ b/app/models/mute.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Mute < ApplicationRecord
+ include Paginable
+
+ belongs_to :account
+ belongs_to :target_account, class_name: 'Account'
+
+ validates :account, :target_account, presence: true
+ validates :account_id, uniqueness: { scope: :target_account_id }
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 3796253d43..31e1ee198a 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -2,7 +2,6 @@
class Setting < RailsSettings::Base
source Rails.root.join('config/settings.yml')
- namespace Rails.env
def to_param
var
diff --git a/app/models/status.rb b/app/models/status.rb
index 1b40897f36..663ac1e34d 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -37,6 +37,9 @@ class Status < ApplicationRecord
scope :remote, -> { where.not(uri: nil) }
scope :local, -> { where(uri: nil) }
+ scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
+ scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
+
cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
def reply?
@@ -109,8 +112,8 @@ class Status < ApplicationRecord
def as_public_timeline(account = nil, local_only = false)
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
.where(visibility: :public)
- .where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)')
- .where('statuses.reblog_of_id IS NULL')
+ .without_replies
+ .without_reblogs
query = query.where('accounts.domain IS NULL') if local_only
@@ -121,7 +124,7 @@ class Status < ApplicationRecord
query = tag.statuses
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
.where(visibility: :public)
- .where('statuses.reblog_of_id IS NULL')
+ .without_reblogs
query = query.where('accounts.domain IS NULL') if local_only
@@ -168,9 +171,9 @@ class Status < ApplicationRecord
private
def filter_timeline(query, account)
- blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
- query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?
- query = query.where('accounts.silenced = TRUE') if account.silenced?
+ blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id)
+ query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? # Only give us statuses from people we haven't blocked, or muted, or that have blocked us
+ query = query.where('accounts.silenced = TRUE') if account.silenced? # and if we're hellbanned, only people who are also hellbanned
query
end
@@ -192,6 +195,6 @@ class Status < ApplicationRecord
private
def filter_from_context?(status, account)
- account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
+ account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
end
end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 77a73cce8b..0d2fe43b8e 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,7 +3,7 @@
class Tag < ApplicationRecord
has_and_belongs_to_many :statuses
- HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
+ HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
validates :name, presence: true, uniqueness: true
diff --git a/app/models/user.rb b/app/models/user.rb
index 08aac26795..bf2916d904 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,9 +14,10 @@ class User < ApplicationRecord
validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
validates :email, email: true
- scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
- scope :recent, -> { order('id desc') }
- scope :admins, -> { where(admin: true) }
+ scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
+ scope :recent, -> { order('id desc') }
+ scope :admins, -> { where(admin: true) }
+ scope :confirmed, -> { where.not(confirmed_at: nil) }
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
new file mode 100644
index 0000000000..0050cfc8d0
--- /dev/null
+++ b/app/services/mute_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class MuteService < BaseService
+ def call(account, target_account)
+ return if account.id == target_account.id
+ clear_home_timeline(account, target_account)
+ account.mute!(target_account)
+ end
+
+ private
+
+ def clear_home_timeline(account, target_account)
+ home_key = FeedManager.instance.key(:home, account.id)
+
+ target_account.statuses.select('id').find_each do |status|
+ redis.zrem(home_key, status.id)
+ end
+ end
+
+ def redis
+ Redis.current
+ end
+end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 5d952df6fd..69911abc59 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
status.save!
- NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+ notify_about_mentions!(status) unless status.reblog?
+ notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
DistributionWorker.perform_async(status.id)
status
end
+ def notify_about_mentions!(status)
+ status.mentions.includes(:account).each do |mention|
+ mentioned_account = mention.account
+ next unless mentioned_account.local?
+ NotifyService.new.call(mentioned_account, mention)
+ end
+ end
+
+ def notify_about_reblog!(status)
+ NotifyService.new.call(status.reblog.account, status)
+ end
+
def delete_status
Rails.logger.debug "Deleting remote status #{id}"
status = Status.find_by(uri: id)
@@ -159,10 +172,7 @@ class ProcessFeedService < BaseService
next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
- mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
-
- # Notify local user
- NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
+ mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
# So we can skip duplicate mentions
processed_account_ids << mentioned_account.id
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index d3d3af8af2..aa0a4d71bb 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -27,7 +27,7 @@ class ProcessMentionsService < BaseService
mentioned_account.mentions.where(status: status).first_or_create(status: status)
end
- status.mentions.each do |mention|
+ status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account
if mentioned_account.local?
diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb
new file mode 100644
index 0000000000..6aeea358f7
--- /dev/null
+++ b/app/services/unmute_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class UnmuteService < BaseService
+ def call(account, target_account)
+ return unless account.muting?(target_account)
+
+ account.unmute!(target_account)
+
+ MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
+ end
+end
diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl
index 22b37586e5..d6f1dd48ab 100644
--- a/app/views/api/v1/accounts/relationship.rabl
+++ b/app/views/api/v1/accounts/relationship.rabl
@@ -4,4 +4,5 @@ attribute :id
node(:following) { |account| @following[account.id] || false }
node(:followed_by) { |account| @followed_by[account.id] || false }
node(:blocking) { |account| @blocking[account.id] || false }
+node(:muting) { |account| @muting[account.id] || false }
node(:requested) { |account| @requested[account.id] || false }
diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl
index 0b42e6e3d0..916217cbde 100644
--- a/app/views/api/v1/media/create.rabl
+++ b/app/views/api/v1/media/create.rabl
@@ -1,5 +1,5 @@
object @media
attribute :id, :type
-node(:url) { |media| full_asset_url(media.file.url( :original)) }
-node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
+node(:url) { |media| full_asset_url(media.file.url(:original)) }
+node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
node(:text_url) { |media| medium_url(media) }
diff --git a/app/views/api/v1/mutes/index.rabl b/app/views/api/v1/mutes/index.rabl
new file mode 100644
index 0000000000..9f3b13a53d
--- /dev/null
+++ b/app/views/api/v1/mutes/index.rabl
@@ -0,0 +1,2 @@
+collection @accounts
+extends 'api/v1/accounts/show'
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index ae52173b54..21bf444c37 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,5 +1,5 @@
<%= yield %>
-
---
<%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
+<%= t('application_mailer.settings', link: settings_preferences_url) %>
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index b089a7b739..85a0136b7b 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -1,3 +1,3 @@
-<%= strip_tags(@status.content) %>
+<%= raw Formatter.instance.plaintext(status) %>
-<%= web_url("statuses/#{@status.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb
new file mode 100644
index 0000000000..95aed6793f
--- /dev/null
+++ b/app/views/notification_mailer/digest.text.erb
@@ -0,0 +1,15 @@
+<%= display_name(@me) %>,
+
+<%= raw t('notification_mailer.digest.body', since: @since, instance: root_url) %>
+<% @notifications.each do |notification| %>
+
+* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.acct) %>
+
+ <%= raw Formatter.instance.plaintext(notification.target_status) %>
+
+ <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
+<% end %>
+<% if @follows_since > 0 %>
+
+<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %>
+<% end %>
diff --git a/app/views/notification_mailer/favourite.text.erb b/app/views/notification_mailer/favourite.text.erb
index b2e1e3e9e8..99852592f6 100644
--- a/app/views/notification_mailer/favourite.text.erb
+++ b/app/views/notification_mailer/favourite.text.erb
@@ -1,5 +1,5 @@
<%= display_name(@me) %>,
-<%= t('notification_mailer.favourite.body', name: @account.acct) %>
+<%= raw t('notification_mailer.favourite.body', name: @account.acct) %>
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/follow.text.erb b/app/views/notification_mailer/follow.text.erb
index 4b2ec142c2..af41a3080e 100644
--- a/app/views/notification_mailer/follow.text.erb
+++ b/app/views/notification_mailer/follow.text.erb
@@ -1,5 +1,5 @@
<%= display_name(@me) %>,
-<%= t('notification_mailer.follow.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow.body', name: @account.acct) %>
-<%= web_url("accounts/#{@account.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("accounts/#{@account.id}") %>
diff --git a/app/views/notification_mailer/follow_request.text.erb b/app/views/notification_mailer/follow_request.text.erb
index c0d38ec672..49087a575e 100644
--- a/app/views/notification_mailer/follow_request.text.erb
+++ b/app/views/notification_mailer/follow_request.text.erb
@@ -1,5 +1,5 @@
<%= display_name(@me) %>,
-<%= t('notification_mailer.follow_request.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow_request.body', name: @account.acct) %>
-<%= web_url("follow_requests") %>
+<%= raw t('application_mailer.view')%> <%= web_url("follow_requests") %>
diff --git a/app/views/notification_mailer/mention.text.erb b/app/views/notification_mailer/mention.text.erb
index 31a294bb94..c0d4be1d86 100644
--- a/app/views/notification_mailer/mention.text.erb
+++ b/app/views/notification_mailer/mention.text.erb
@@ -1,5 +1,5 @@
<%= display_name(@me) %>,
-<%= t('notification_mailer.mention.body', name: @status.account.acct) %>
+<%= raw t('notification_mailer.mention.body', name: @status.account.acct) %>
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/reblog.text.erb b/app/views/notification_mailer/reblog.text.erb
index 7af8052ca4..c32b486500 100644
--- a/app/views/notification_mailer/reblog.text.erb
+++ b/app/views/notification_mailer/reblog.text.erb
@@ -1,5 +1,5 @@
<%= display_name(@me) %>,
-<%= t('notification_mailer.reblog.body', name: @account.acct) %>
+<%= raw t('notification_mailer.reblog.body', name: @account.acct) %>
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index aee0540d2f..a17279b1ed 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -16,6 +16,7 @@
= ff.input :reblog, as: :boolean, wrapper: :with_label
= ff.input :favourite, as: :boolean, wrapper: :with_label
= ff.input :mention, as: :boolean, wrapper: :with_label
+ = ff.input :digest, as: :boolean, wrapper: :with_label
= f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
= ff.input :must_be_follower, as: :boolean, wrapper: :with_label
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 235dc60863..8c0456b1fe 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -10,7 +10,7 @@
.status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank?
%p= status.spoiler_text
- = Formatter.instance.format(status)
+ %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty?
- if status.media_attachments.first.video?
@@ -22,9 +22,9 @@
.detailed-status__attachments
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- - status.media_attachments.each do |media|
- .media-item
- = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+ .status__attachments__inner
+ - status.media_attachments.each do |media|
+ = render partial: 'stream_entries/media', locals: { media: media }
%div.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml
new file mode 100644
index 0000000000..cd7faa7002
--- /dev/null
+++ b/app/views/stream_entries/_media.html.haml
@@ -0,0 +1,4 @@
+.media-item
+ = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
+ - unless media.image?
+ %video{ src: media.file.url(:original), autoplay: true, loop: true }/
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 95f90abd97..cb2c976ce4 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -15,18 +15,19 @@
.status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank?
%p= status.spoiler_text
- = Formatter.instance.format(status)
+ %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty?
.status__attachments
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- if status.media_attachments.first.video?
- .video-item
- = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
- .video-item__play
- = fa_icon('play')
+ .status__attachments__inner
+ .video-item
+ = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
+ .video-item__play
+ = fa_icon('play')
- else
- - status.media_attachments.each do |media|
- .media-item
- = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+ .status__attachments__inner
+ - status.media_attachments.each do |media|
+ = render partial: 'stream_entries/media', locals: { media: media }
diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb
new file mode 100644
index 0000000000..dedb21e4ea
--- /dev/null
+++ b/app/workers/digest_mailer_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class DigestMailerWorker
+ include Sidekiq::Worker
+
+ sidekiq_options queue: 'mailers'
+
+ def perform(user_id)
+ user = User.find(user_id)
+ return unless user.settings.notification_emails['digest']
+ NotificationMailer.digest(user.account).deliver_now!
+ user.touch(:last_emailed_at)
+ end
+end
diff --git a/config/application.rb b/config/application.rb
index 8da5ade3cf..cb009b24c1 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -2,12 +2,14 @@ require_relative 'boot'
require 'rails/all'
-require_relative '../app/lib/exceptions'
-
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
+require_relative '../app/lib/exceptions'
+require_relative '../lib/paperclip/gif_transcoder'
+require_relative '../lib/paperclip/video_transcoder'
+
Dotenv::Railtie.load
module Mastodon
@@ -49,12 +51,5 @@ module Mastodon
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
Doorkeeper::Application.send :include, ApplicationExtension
end
-
- config.action_dispatch.default_headers = {
- 'Server' => 'Mastodon',
- 'X-Frame-Options' => 'DENY',
- 'X-Content-Type-Options' => 'nosniff',
- 'X-XSS-Protection' => '1; mode=block',
- }
end
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 67ff639142..dc5dd4afd4 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -109,4 +109,11 @@ Rails.application.configure do
config.to_prepare do
StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
end
+
+ config.action_dispatch.default_headers = {
+ 'Server' => 'Mastodon',
+ 'X-Frame-Options' => 'DENY',
+ 'X-Content-Type-Options' => 'nosniff',
+ 'X-XSS-Protection' => '1; mode=block',
+ }
end
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 71a7b514ec..580a3196e6 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -2,6 +2,11 @@
Paperclip.options[:read_timeout] = 60
+Paperclip.interpolates :filename do |attachment, style|
+ return attachment.original_filename if style == :original
+ [basename(attachment, style), extension(attachment, style)].delete_if(&:empty?).join('.')
+end
+
if ENV['S3_ENABLED'] == 'true'
Aws.eager_autoload!(services: %w(S3))
diff --git a/config/initializers/rabl_init.rb b/config/initializers/rabl_init.rb
index 325bf0c789..f7be0c607a 100644
--- a/config/initializers/rabl_init.rb
+++ b/config/initializers/rabl_init.rb
@@ -1,6 +1,6 @@
Rabl.configure do |config|
config.cache_all_output = false
- config.cache_sources = !!Rails.env.production?
+ config.cache_sources = Rails.env.production?
config.include_json_root = false
config.view_paths = [Rails.root.join('app/views')]
end
diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb
index 3f0ee1d7a1..70f7846d19 100644
--- a/config/initializers/rack-attack.rb
+++ b/config/initializers/rack-attack.rb
@@ -1,6 +1,6 @@
class Rack::Attack
# Rate limits for the API
- throttle('api', limit: 150, period: 5.minutes) do |req|
+ throttle('api', limit: 300, period: 5.minutes) do |req|
req.ip if req.path.match(/\A\/api\/v/)
end
@@ -11,7 +11,7 @@ class Rack::Attack
headers = {
'X-RateLimit-Limit' => match_data[:limit].to_s,
'X-RateLimit-Remaining' => '0',
- 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
+ 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6),
}
[429, headers, [{ error: 'Throttled' }.to_json]]
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6da30acdad..f11a689e44 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -29,6 +29,8 @@ en:
unfollow: Unfollow
application_mailer:
signature: Mastodon notifications from %{instance}
+ settings: 'Change e-mail preferences: %{link}'
+ view: 'View:'
applications:
invalid_url: The provided URL is invalid
auth:
@@ -83,6 +85,15 @@ en:
reblog:
body: 'Your status was boosted by %{name}:'
subject: "%{name} boosted your status"
+ digest:
+ subject:
+ one: "1 new notification since your last visit 🐘"
+ other: "%{count} new notifications since your last visit 🐘"
+ body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
+ mention: "%{name} mentioned you in:"
+ new_followers_summary:
+ one: You have acquired one new follower! Yay!
+ other: You have gotten %{count} new followers! Amazing!
pagination:
next: Next
prev: Prev
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 4d1758f82e..170af01cfc 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -34,6 +34,7 @@ en:
follow_request: Send e-mail when someone requests to follow you
mention: Send e-mail when someone mentions you
reblog: Send e-mail when someone reblogs your status
+ digest: Send digest e-mails
'no': 'No'
required:
mark: "*"
diff --git a/config/routes.rb b/config/routes.rb
index 4595b4ba38..1a2e3c19d7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -127,6 +127,7 @@ Rails.application.routes.draw do
resources :media, only: [:create]
resources :apps, only: [:create]
resources :blocks, only: [:index]
+ resources :mutes, only: [:index]
resources :favourites, only: [:index]
resources :reports, only: [:index, :create]
resources :site, only: [:index]
@@ -153,7 +154,6 @@ Rails.application.routes.draw do
member do
get :statuses
- get 'statuses/media', to: 'accounts#media_statuses', as: :media_statuses
get :followers
get :following
@@ -161,6 +161,8 @@ Rails.application.routes.draw do
post :unfollow
post :block
post :unblock
+ post :mute
+ post :unmute
end
end
end
@@ -178,5 +180,8 @@ Rails.application.routes.draw do
root 'home#index'
+ get '/:username', to: redirect('/users/%{username}')
+ get '/:username/:id', to: redirect('/users/%{username}/updates/%{id}')
+
match '*unmatched_route', via: :all, to: 'application#raise_not_found'
end
diff --git a/config/settings.yml b/config/settings.yml
index 71ce12e632..6ae9217a4a 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -11,6 +11,7 @@ defaults: &defaults
favourite: false
mention: false
follow_request: true
+ digest: true
interactions:
must_be_follower: false
must_be_following: false
diff --git a/db/migrate/20170301222600_create_mutes.rb b/db/migrate/20170301222600_create_mutes.rb
new file mode 100644
index 0000000000..8f1bb22f5b
--- /dev/null
+++ b/db/migrate/20170301222600_create_mutes.rb
@@ -0,0 +1,12 @@
+class CreateMutes < ActiveRecord::Migration[5.0]
+ def change
+ create_table :mutes do |t|
+ t.integer :account_id, null: false
+ t.integer :target_account_id, null: false
+ t.timestamps null: false
+ end
+
+ add_index :mutes, [:account_id, :target_account_id], unique: true
+
+ end
+end
diff --git a/db/migrate/20170303212857_add_last_emailed_at_to_users.rb b/db/migrate/20170303212857_add_last_emailed_at_to_users.rb
new file mode 100644
index 0000000000..9ae3da4fbb
--- /dev/null
+++ b/db/migrate/20170303212857_add_last_emailed_at_to_users.rb
@@ -0,0 +1,5 @@
+class AddLastEmailedAtToUsers < ActiveRecord::Migration[5.0]
+ def change
+ add_column :users, :last_emailed_at, :datetime, null: true, default: nil
+ end
+end
diff --git a/db/migrate/20170304202101_add_type_to_media_attachments.rb b/db/migrate/20170304202101_add_type_to_media_attachments.rb
new file mode 100644
index 0000000000..5140799580
--- /dev/null
+++ b/db/migrate/20170304202101_add_type_to_media_attachments.rb
@@ -0,0 +1,12 @@
+class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
+ def up
+ add_column :media_attachments, :type, :integer, default: 0, null: false
+
+ MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
+ MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
+ end
+
+ def down
+ remove_column :media_attachments, :type
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fa5c40774c..4ec85ef2bf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170217012631) do
+ActiveRecord::Schema.define(version: 20170304202101) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "shortcode"
+ t.integer "type", default: 0, null: false
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
end
@@ -110,6 +111,14 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
end
+ create_table "mutes", force: :cascade do |t|
+ t.integer "account_id", null: false
+ t.integer "target_account_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true, using: :btree
+ end
+
create_table "notifications", force: :cascade do |t|
t.integer "account_id"
t.integer "activity_id"
@@ -275,6 +284,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.string "encrypted_otp_secret_salt"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login"
+ t.datetime "last_emailed_at"
t.index ["account_id"], name: "index_users_on_account_id", using: :btree
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
diff --git a/docs/Contributing-to-Mastodon/Sponsors.md b/docs/Contributing-to-Mastodon/Sponsors.md
index 3fee6e1e03..4757916847 100644
--- a/docs/Contributing-to-Mastodon/Sponsors.md
+++ b/docs/Contributing-to-Mastodon/Sponsors.md
@@ -6,23 +6,16 @@ These people make the development of Mastodon possible through [Patreon](https:/
**Extra special Patrons**
- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
-- [glocal](https://mastodon.social/users/glocal)
- [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
-- [Zeiphner](https://mastodon.social/users/Zeipher)
+- [Zeipher](https://mastodon.social/users/Zeipher)
- [Effy Elden](https://toot.zone/users/effy)
- [Zoë Quinn](https://mastodon.social/users/zoequinn)
**Thank you to the following people**
-- [Sophia Park](https://mastodon.social/users/sophia)
-- [WelshPixie](https://mastodon.social/users/WelshPixie)
-- [John Parker](https://mastodon.social/users/Middaparka)
-- [Christina Hendricks](https://mastodon.social/users/clhendricksbc)
-- [Jelle](http://jelv.nl)
- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
-- [Martin Tithonium](https://mastodon.social/users/tithonium)
- [Edward Saperia](https://nwspk.com)
- [Yoz Grahame](http://yoz.com/)
- [Jenn Kaplan](https://gay.crime.team/users/jkap)
@@ -33,5 +26,21 @@ These people make the development of Mastodon possible through [Patreon](https:/
- [Niels Roesen Abildgaard](http://hypesystem.dk/)
- [Zatnosk](https://github.com/Zatnosk)
- [Spex Bluefox](https://mastodon.social/users/Spex)
-- [Sam Waldie](https://mastodon.social/users/denjin)
- [J. C. Holder](http://jcholder.com/)
+- [glocal](https://mastodon.social/users/glocal)
+- [jk](https://mastodon.social/users/jk)
+- [C418](https://mastodon.social/users/C418)
+- [halcy](https://icosahedron.website/users/halcy)
+- [Extropic](https://gnusocial.no/extropic)
+- [Pat Monaghan](http://iwrite.software/)
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
diff --git a/docs/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md
index e350e5f95c..62d48c5eca 100644
--- a/docs/Using-Mastodon/Apps.md
+++ b/docs/Using-Mastodon/Apps.md
@@ -5,11 +5,13 @@ Some people have started working on apps for the Mastodon API. Here is a list of
|App|Platform|Link|Developer(s)|
|---|--------|----|------------|
-|Matodor|iOS/Android||[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
|Tusky|Android||[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
-|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
-|tootstream|command-line||[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
-|mastodroid|Android|||
-|Tooter|Chrome extension||[@effy@mastodon.social](https://mastodon.social/users/effy)|
+|mastodroid|Android||[@charlag@mastodon.social](https://mastodon.social/users/charlag)|
|TootyFruity|Android||[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)|
+|Matodor|iOS/Android||[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
+|Amarok|iOS||[@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy)|
+|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
+|Tooter|Chrome||[@effy@mastodon.social](https://mastodon.social/users/effy)|
+|tootstream|CLI||[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
+
If you have a project like this, let me know so I can add it to the list!
diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md
index ed3c742944..ef3c835de4 100644
--- a/docs/Using-Mastodon/List-of-Mastodon-instances.md
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -11,8 +11,9 @@ List of Known Mastodon instances
| [epiktistes.com](https://epiktistes.com) |N/A|Yes|
| [on.vu](https://on.vu) | Appears defunct|No|
| [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
-| [gnusocial.me](https://gnusocial.me) |Yes, it's a mastodon instance now|Yes|
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
| [memetastic.space](https://memetastic.space) |Memes|Yes|
+| [social.diskseven.com](https://social.diskseven.com) |Single user|No|
+| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|
Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md
index 8dcf2a842f..2c323d559d 100644
--- a/docs/Using-the-API/API.md
+++ b/docs/Using-the-API/API.md
@@ -76,6 +76,10 @@ Query parameters:
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
+Query parameters for public and tag timelines only:
+
+- `local` (optional): Only return statuses originating from this instance
+
### Notifications
**GET /api/v1/notifications**
@@ -116,7 +120,14 @@ Returns authenticated user's account.
**GET /api/v1/accounts/:id/statuses**
-Returns statuses by user. Same options as timeline are permitted.
+Returns statuses by user.
+
+Query parameters:
+
+- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
+- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
+- `only_media` (optional): Only return statuses that have media attachments
+- `exclude_replies` (optional): Skip statuses that reply to other statuses
**GET /api/v1/accounts/:id/following**
@@ -128,7 +139,7 @@ Returns users the given user is followed by.
**GET /api/v1/accounts/relationships**
-Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts.
+Returns relationships (`following`, `followed_by`, `blocking`, `muting`, `requested`) of the current user to a list of given accounts.
Query parameters:
@@ -147,6 +158,14 @@ Query parameters:
Returns accounts blocked by authenticated user.
+**GET /api/v1/mutes**
+
+Returns accounts muted by authenticated user.
+
+**GET /api/v1/follow_requests**
+
+Returns accounts that want to follow the authenticated user but are waiting for approval.
+
**GET /api/v1/favourites**
Returns statuses favourited by authenticated user.
@@ -215,6 +234,13 @@ Returns the updated relationship to the user.
Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site.
Does not require authentication.
+# Muting and unmuting users
+
+**POST /api/v1/accounts/:id/mute**
+**POST /api/v1/accounts/:id/unmute**
+
+Returns the updated relationship to the user.
+
### OAuth apps
**POST /api/v1/apps**
diff --git a/docs/Using-the-API/Push-notifications.md b/docs/Using-the-API/Push-notifications.md
index d98c8833aa..fc373e7231 100644
--- a/docs/Using-the-API/Push-notifications.md
+++ b/docs/Using-the-API/Push-notifications.md
@@ -1,4 +1,4 @@
Push notifications
==================
-**Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration**
+See for an example of how to create push notifications for a mobile app. It involves using the Mastodon streaming API on behalf of the app's users, as a sort of proxy.
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
new file mode 100644
index 0000000000..8337448b23
--- /dev/null
+++ b/lib/paperclip/gif_transcoder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Paperclip
+ # This transcoder is only to be used for the MediaAttachment model
+ # to convert animated gifs to webm
+ class GifTranscoder < Paperclip::Processor
+ def make
+ num_frames = identify('-format %n :file', file: file.path).to_i
+
+ return file unless options[:style] == :original && num_frames > 1
+
+ final_file = Paperclip::Transcoder.make(file, options, attachment)
+
+ attachment.instance.file_file_name = 'media.mp4'
+ attachment.instance.file_content_type = 'video/mp4'
+ attachment.instance.type = MediaAttachment.types[:gifv]
+
+ final_file
+ end
+ end
+end
diff --git a/lib/paperclip/video_transcoder.rb b/lib/paperclip/video_transcoder.rb
new file mode 100644
index 0000000000..c3504c17c4
--- /dev/null
+++ b/lib/paperclip/video_transcoder.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Paperclip
+ # This transcoder is only to be used for the MediaAttachment model
+ # to check when uploaded videos are actually gifv's
+ class VideoTranscoder < Paperclip::Processor
+ def make
+ meta = ::Av.cli.identify(@file.path)
+ attachment.instance.type = MediaAttachment.types[:gifv] unless meta[:audio_encode]
+
+ Paperclip::Transcoder.make(file, options, attachment)
+ end
+ end
+end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 8482d41247..bb10410b50 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -43,7 +43,7 @@ namespace :mastodon do
namespace :feeds do
desc 'Clear timelines of inactive users'
task clear: :environment do
- User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
+ User.confirmed.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
Redis.current.del(FeedManager.instance.key(:home, user.account_id))
end
end
@@ -53,4 +53,13 @@ namespace :mastodon do
Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
end
end
+
+ namespace :emails do
+ desc 'Send out digest e-mails'
+ task digest: :environment do
+ User.confirmed.joins(:account).where(accounts: { silenced: false, suspended: false }).where('current_sign_in_at < ?', 20.days.ago).find_each do |user|
+ DigestMailerWorker.perform_async(user.id)
+ end
+ end
+ end
end
diff --git a/package.json b/package.json
index 45702d5f43..35ce56ee52 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"css-loader": "^0.26.2",
"dotenv": "^4.0.0",
"emojione": "latest",
+ "emojione-picker": "^2.0.1",
"enzyme": "^2.7.1",
"es6-promise": "^3.2.1",
"escape-html": "^1.0.3",
@@ -40,6 +41,7 @@
"react": "^15.4.2",
"react-addons-perf": "^15.4.2",
"react-addons-pure-render-mixin": "^15.4.2",
+ "react-addons-shallow-compare": "^15.4.2",
"react-addons-test-utils": "^15.4.2",
"react-autosuggest": "^7.0.1",
"react-decoration": "^1.4.0",
@@ -60,7 +62,6 @@
"redis": "^2.6.5",
"redux": "^3.6.0",
"redux-immutable": "^3.1.0",
- "redux-sounds": "^1.1.1",
"redux-thunk": "^2.2.0",
"reselect": "^2.5.4",
"sass-loader": "^6.0.2",
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 98b284f7a4..5d36b01591 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -116,6 +116,44 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
end
end
+ describe 'POST #mute' do
+ let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
+
+ before do
+ user.account.follow!(other_account)
+ post :mute, params: {id: other_account.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'does not remove the following relation between user and target user' do
+ expect(user.account.following?(other_account)).to be true
+ end
+
+ it 'creates a muting relation' do
+ expect(user.account.muting?(other_account)).to be true
+ end
+ end
+
+ describe 'POST #unmute' do
+ let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
+
+ before do
+ user.account.mute!(other_account)
+ post :unmute, params: { id: other_account.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'removes the muting relation between user and target user' do
+ expect(user.account.muting?(other_account)).to be false
+ end
+ end
+
describe 'GET #relationships' do
let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account }
let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account }
diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb
new file mode 100644
index 0000000000..be8a5e7dd2
--- /dev/null
+++ b/spec/controllers/api/v1/mutes_controller_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::MutesController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+ let(:token) { double acceptable?: true, resource_owner_id: user.id }
+
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ end
+
+ describe 'GET #index' do
+ it 'returns http success' do
+ get :index
+ expect(response).to have_http_status(:success)
+ end
+ end
+end
diff --git a/spec/fabricators/mute_fabricator.rb b/spec/fabricators/mute_fabricator.rb
new file mode 100644
index 0000000000..fc150c1d6b
--- /dev/null
+++ b/spec/fabricators/mute_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:mute) do
+
+end
diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb
index 8fc8d0d341..a08a80d175 100644
--- a/spec/mailers/previews/notification_mailer_preview.rb
+++ b/spec/mailers/previews/notification_mailer_preview.rb
@@ -1,24 +1,31 @@
# Preview all emails at http://localhost:3000/rails/mailers/notification_mailer
class NotificationMailerPreview < ActionMailer::Preview
-
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
def mention
- # NotificationMailer.mention
+ m = Mention.last
+ NotificationMailer.mention(m.account, Notification.find_by(activity: m))
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow
def follow
- # NotificationMailer.follow
+ f = Follow.last
+ NotificationMailer.follow(f.target_account, Notification.find_by(activity: f))
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
def favourite
- # NotificationMailer.favourite
+ f = Favourite.last
+ NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f))
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog
def reblog
- # NotificationMailer.reblog
+ r = Status.where.not(reblog_of_id: nil).first
+ NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r))
end
+ # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest
+ def digest
+ NotificationMailer.digest(Account.first, since: 90.days.ago)
+ end
end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 287f389ac9..91c8d75cf4 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -178,7 +178,6 @@ RSpec.describe Account, type: :model do
end
end
-
describe 'MENTION_RE' do
subject { Account::MENTION_RE }
@@ -190,6 +189,14 @@ RSpec.describe Account, type: :model do
expect(subject.match('@alice Hey how are you?')[1]).to eq 'alice'
end
+ it 'matches full usernames' do
+ expect(subject.match('@alice@example.com')[1]).to eq 'alice@example.com'
+ end
+
+ it 'matches full usernames with a dot at the end' do
+ expect(subject.match('Hello @alice@example.com.')[1]).to eq 'alice@example.com'
+ end
+
it 'matches dot-prepended usernames' do
expect(subject.match('.@alice I want everybody to see this')[1]).to eq 'alice'
end
diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb
new file mode 100644
index 0000000000..83ba793b2b
--- /dev/null
+++ b/spec/models/mute_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Mute, type: :model do
+
+end
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index 9a7f481e48..360bbc16de 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -1,5 +1,15 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
+ describe 'HASHTAG_RE' do
+ subject { Tag::HASHTAG_RE }
+ it 'does not match URLs with anchors with non-hashtag characters' do
+ expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil
+ end
+
+ it 'does not match URLs with hashtag-like anchors' do
+ expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil
+ end
+ end
end
diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb
new file mode 100644
index 0000000000..3973684169
--- /dev/null
+++ b/spec/services/mute_service_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe MuteService do
+ subject { MuteService.new }
+end
diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb
new file mode 100644
index 0000000000..5dc971fb16
--- /dev/null
+++ b/spec/services/unmute_service_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe UnmuteService do
+ subject { UnmuteService.new }
+end
diff --git a/storybook/stories/autosuggest_textarea.story.jsx b/storybook/stories/autosuggest_textarea.story.jsx
index 7d84ff1e15..72a4b525d6 100644
--- a/storybook/stories/autosuggest_textarea.story.jsx
+++ b/storybook/stories/autosuggest_textarea.story.jsx
@@ -2,5 +2,5 @@ import { storiesOf } from '@kadira/storybook';
import AutosuggestTextarea from '../../app/assets/javascripts/components/components/autosuggest_textarea.jsx'
storiesOf('AutosuggestTextarea', module)
- .add('default state', () => )
- .add('with text', () => )
+ .add('default state', () => )
+ .add('with text', () => )
diff --git a/streaming/index.js b/streaming/index.js
index 125b35bb44..0f838e411a 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -164,7 +164,7 @@ const streamFrom = (id, req, output, attachCloseHandler, needsFiltering = false)
const unpackedPayload = JSON.parse(payload)
const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : [])
- client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
+ client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT target_account_id FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
done()
if (err) {
diff --git a/yarn.lock b/yarn.lock
index a77fe59eb0..0904354e95 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2204,7 +2204,7 @@ doctrine@^2.0.0:
esutils "^2.0.2"
isarray "^1.0.0"
-dom-helpers@^2.4.0:
+dom-helpers@^2.4.0, "dom-helpers@^2.4.0 || ^3.0.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-2.4.0.tgz#9bb4b245f637367b1fa670274272aa28fe06c367"
@@ -2287,7 +2287,17 @@ elliptic@^6.0.0:
hash.js "^1.0.0"
inherits "^2.0.1"
-emojione@latest:
+emojione-picker@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/emojione-picker/-/emojione-picker-2.0.1.tgz#62e58db67d37a400a883c82d39abb1cc1c8ed65a"
+ dependencies:
+ emojione "^2.2.6"
+ escape-string-regexp "^1.0.5"
+ lodash "^4.15.0"
+ react-virtualized "^8.11.4"
+ store "^1.3.20"
+
+emojione@^2.2.6, emojione@latest:
version "2.2.7"
resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.7.tgz#46457cf6b9b2f8da13ae8a2e4e547de06ee15e96"
@@ -2413,7 +2423,7 @@ escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
-escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2:
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@@ -2905,10 +2915,6 @@ hosted-git-info@^2.1.4:
version "2.1.5"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b"
-howler@^1.1.28:
- version "1.1.29"
- resolved "https://registry.yarnpkg.com/howler/-/howler-1.1.29.tgz#9a3a7fa69e9b9d805c65ad98f66e35893a597b63"
-
html-comment-regex@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
@@ -3632,7 +3638,7 @@ lodash@4.x.x, lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lod
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
-lodash@^4.2.0, lodash@^4.6.1, lodash@~4.16.4:
+lodash@^4.15.0, lodash@^4.2.0, lodash@^4.6.1, lodash@~4.16.4:
version "4.16.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.16.4.tgz#01ce306b9bad1319f2a5528674f88297aeb70127"
@@ -3644,7 +3650,13 @@ longest@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
-loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0:
+loose-envify@^1.0.0, loose-envify@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
+ dependencies:
+ js-tokens "^3.0.0"
+
+loose-envify@^1.1.0, loose-envify@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.2.0.tgz#69a65aad3de542cf4ee0f4fe74e8e33c709ccb0f"
dependencies:
@@ -4833,6 +4845,13 @@ react-addons-pure-render-mixin@>=0.14.0, react-addons-pure-render-mixin@^15.4.2:
fbjs "^0.8.4"
object-assign "^4.1.0"
+react-addons-shallow-compare@^15.4.2:
+ version "15.4.2"
+ resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz#027ffd9720e3a1e0b328dcd8fc62e214a0d174a5"
+ dependencies:
+ fbjs "^0.8.4"
+ object-assign "^4.1.0"
+
react-addons-test-utils@^15.4.2:
version "15.4.2"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.4.2.tgz#93bcaa718fcae7360d42e8fb1c09756cc36302a2"
@@ -5051,6 +5070,15 @@ react-toggle@^2.1.1:
classnames "~2.2"
react-addons-pure-render-mixin ">=0.14.0"
+react-virtualized@^8.11.4:
+ version "8.11.4"
+ resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-8.11.4.tgz#0bb94f1ecbd286d07145ce63983d0a11724522c0"
+ dependencies:
+ babel-runtime "^6.11.6"
+ classnames "^2.2.3"
+ dom-helpers "^2.4.0 || ^3.0.0"
+ loose-envify "^1.3.0"
+
react@^15.4.2:
version "15.4.2"
resolved "https://registry.yarnpkg.com/react/-/react-15.4.2.tgz#41f7991b26185392ba9bae96c8889e7e018397ef"
@@ -5170,12 +5198,6 @@ redux-immutable@^3.1.0:
dependencies:
immutable "^3.8.1"
-redux-sounds@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/redux-sounds/-/redux-sounds-1.1.1.tgz#7a31052dbc617d419c53056215865762f44adb7e"
- dependencies:
- howler "^1.1.28"
-
redux-thunk@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
@@ -5623,6 +5645,10 @@ stdout-stream@^1.4.0:
dependencies:
readable-stream "^2.0.1"
+store@^1.3.20:
+ version "1.3.20"
+ resolved "https://registry.yarnpkg.com/store/-/store-1.3.20.tgz#13ea7e3fb2d6c239868265d686b1d84e99c5be3e"
+
stream-browserify@^2.0.0, stream-browserify@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"