diff options
author | Eugen <eugen@zeonfederated.com> | 2017-03-15 22:55:22 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-03-15 22:55:22 +0100 |
commit | e245115f47082ffba27205f508301d14e792c369 (patch) | |
tree | 21a77b788dace45b734da6e64f1b0705016192f0 /app | |
parent | 620f70e42c16c324459ca2da52c68f1def8683de (diff) | |
parent | c1124228e857b0e85f5bf927d2c41c7fadfdf955 (diff) |
Merge branch 'master' into mastodon-site-api
Diffstat (limited to 'app')
75 files changed, 1242 insertions, 300 deletions
diff --git a/app/assets/images/mastodon-not-found.png b/app/assets/images/mastodon-not-found.png new file mode 100644 index 000000000..76108d41f --- /dev/null +++ b/app/assets/images/mastodon-not-found.png Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 47c0d9f85..05fa8e68d 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; @@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export function fetchAccount(id) { return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + dispatch(fetchAccountRequest(id)); api(getState).get(`/api/v1/accounts/${id}`).then(response => { dispatch(fetchAccountSuccess(response.data)); - dispatch(fetchRelationships([id])); }).catch(error => { dispatch(fetchAccountFail(id, error)); }); @@ -328,6 +341,76 @@ export function unblockAccountFail(error) { }; }; + +export function muteAccount(id) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error + }; +}; + + export function fetchFollowers(id) { return (dispatch, getState) => { dispatch(fetchFollowersRequest(id)); diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 8d030fd30..165e811e3 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -28,6 +28,8 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -85,8 +87,13 @@ export function submitCompose() { dispatch(updateTimeline('home', { ...response.data })); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { - dispatch(updateTimeline('community', { ...response.data })); - dispatch(updateTimeline('public', { ...response.data })); + if (getState().getIn(['timelines', 'community', 'loaded'])) { + dispatch(updateTimeline('community', { ...response.data })); + } + + if (getState().getIn(['timelines', 'public', 'loaded'])) { + dispatch(updateTimeline('public', { ...response.data })); + } } }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -255,3 +262,11 @@ export function changeComposeListability(checked) { checked }; }; + +export function insertEmojiCompose(position, emoji) { + return { + type: COMPOSE_EMOJI_INSERT, + position, + emoji + }; +}; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 311b08033..3e2d4ff43 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -106,18 +106,20 @@ export function expandTimeline(timeline) { return; } - const next = getState().getIn(['timelines', timeline, 'next']); - const params = getState().getIn(['timelines', timeline, 'params'], {}); - - if (next === null) { + if (getState().getIn(['timelines', timeline, 'items']).size === 0) { return; } + const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id'])); + const params = getState().getIn(['timelines', timeline, 'params'], {}); + const lastId = getState().getIn(['timelines', timeline, 'items']).last(); + dispatch(expandTimelineRequest(timeline)); - api(getState).get(next, { + api(getState).get(path, { params: { ...params, + max_id: lastId, limit: 10 } }).then(response => { diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx index 4e4c2090c..38deeae0e 100644 --- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -1,5 +1,6 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { isRtl } from '../rtl'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -39,7 +40,8 @@ const AutosuggestTextarea = React.createClass({ onSuggestionsFetchRequested: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired, onKeyUp: React.PropTypes.func, - onKeyDown: React.PropTypes.func + onKeyDown: React.PropTypes.func, + onPaste: React.PropTypes.func.isRequired, }, getInitialState () { @@ -172,10 +174,22 @@ const AutosuggestTextarea = React.createClass({ }) }, + onPaste (e) { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files) + e.preventDefault(); + } + }, + render () { const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props; const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state; const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea'; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } return ( <div className='autosuggest-textarea'> @@ -192,6 +206,8 @@ const AutosuggestTextarea = React.createClass({ onBlur={this.onBlur} onDragEnter={this.onDragEnter} onDragExit={this.onDragExit} + onPaste={this.onPaste} + style={style} /> <div style={{ display: (suggestions.size > 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 6abf11239..6b5ffee53 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 0a8492b56..2b42eaa60 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 <li key={i} className='dropdown__sep' />; + } + + const { text, action, href = '#' } = item; + + return ( + <li key={i}> + <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}> + {text} + </a> + </li> + ); + }, + render () { const { icon, items, size, direction } = this.props; const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right"; @@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({ <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}> <ul> - {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => { - if (typeof action === 'function') { - e.preventDefault(); - action(); - this.dropdown.hide(); - } - }}>{text}</a></li>)} + {items.map(this.renderItem)} </ul> </DropdownContent> </Dropdown> 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 000000000..66e5dee16 --- /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 ( + <div> + <video src={this.props.src} autoPlay muted loop /> + </div> + ); + }, + +}); + +export default ExtendedVideoPlayer; diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index b0e397e80..72b5e977f 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 = ( + <a + href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} + onClick={this.handleClick} + target='_blank' + style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }} + /> + ); + } else if (attachment.get('type') === 'gifv') { + thumbnail = ( + <video + src={attachment.get('url')} + onClick={this.handleClick} + autoPlay={!isIOS()} + loop={true} + muted={true} + style={gifvThumbStyle} + /> + ); + } + + return ( + <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +}); + 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 = ( - <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; } else { - children = ( - <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}> - <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> - <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> - </div> - ); + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; } + + children = ( + <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}> + <span style={spoilerSpanStyle}>{warning}</span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); } 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 ( - <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> - <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> - </div> - ); - }); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); } return ( <div style={{ ...outerStyle, height: `${this.props.height}px` }}> - <div style={spoilerButtonStyle} > + <div style={spoilerButtonStyle}> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} /> </div> + {children} </div> ); diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index 35c458b5e..469506f2f 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 43bbb9582..6c25afdea 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} - <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> + <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} /> </div> ); } else { return ( <div className='status__content' - style={{ cursor: 'pointer' }} + style={{ cursor: 'pointer', ...directionStyle }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} dangerouslySetInnerHTML={content} diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index ccd67ddf0..92597a2ec 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.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_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, @@ -61,12 +62,14 @@ const VideoPlayer = React.createClass({ media: ImmutablePropTypes.map.isRequired, width: React.PropTypes.number, height: React.PropTypes.number, - sensitive: React.PropTypes.bool + sensitive: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired, + autoplay: React.PropTypes.bool }, getDefaultProps () { return { - width: 196, + width: 239, height: 110 }; }, @@ -75,7 +78,8 @@ const VideoPlayer = React.createClass({ return { visible: !this.props.sensitive, preview: true, - muted: true + muted: true, + hasAudio: true }; }, @@ -108,8 +112,42 @@ const VideoPlayer = React.createClass({ }); }, + setRef (c) { + this.video = c; + }, + + handleLoadedData () { + if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { + this.setState({ hasAudio: false }); + } + }, + + componentDidMount () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + }, + + componentDidUpdate () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + }, + + componentWillUnmount () { + if (!this.video) { + return; + } + + this.video.removeEventListener('loadeddata', this.handleLoadedData); + }, + render () { - const { media, intl, width, height, sensitive } = this.props; + const { media, intl, width, height, sensitive, autoplay } = this.props; let spoilerButton = ( <div style={spoilerButtonStyle} > @@ -117,6 +155,16 @@ const VideoPlayer = React.createClass({ </div> ); + let muteButton = ''; + + if (this.state.hasAudio) { + muteButton = ( + <div style={muteStyle}> + <IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> + </div> + ); + } + if (!this.state.visible) { if (sensitive) { return ( @@ -128,7 +176,7 @@ const VideoPlayer = React.createClass({ ); } else { return ( - <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}> + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> {spoilerButton} <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> @@ -137,7 +185,7 @@ const VideoPlayer = React.createClass({ } } - if (this.state.preview) { + if (this.state.preview && !autoplay) { return ( <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> {spoilerButton} @@ -149,8 +197,8 @@ const VideoPlayer = React.createClass({ return ( <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> {spoilerButton} - <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div> - <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> + {muteButton} + <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> </div> ); } diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx index 889c0ac4c..3c30be715 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 81265bc50..e7543bc39 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 a2ab8172b..80a32d3e2 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 = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>; } return ( @@ -70,17 +83,17 @@ const ActionBar = React.createClass({ <div style={outerLinksStyle}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> - <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> - <strong><FormattedNumber value={account.get('following_count')} /></strong> + <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> - <strong><FormattedNumber value={account.get('followers_count')} /></strong> + <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong> </Link> </div> </div> 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 2dd3ca7b1..99a10562e 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} /> </div> ); 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 e4ce905fe..8472d25a5 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 aa1b8368e..2cfd7b2fe 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 f0c1b7c8d..e6b675354 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 ( <span style={{ fontSize: '16px', cursor: 'default' }}> 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 31ae8e034..047c974f2 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} /> <div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> - <UploadButtonContainer style={{ paddingTop: '4px' }} /> + <div style={{ display: 'flex', paddingTop: '4px' }}> + <UploadButtonContainer /> + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> </div> <SpoilerToggleContainer /> 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 000000000..3a454a5fb --- /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 ( + <Dropdown ref={this.setRef} style={{ marginLeft: '5px' }}> + <DropdownTrigger className='icon-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}> + <i className={`fa fa-smile-o`} style={{ verticalAlign: 'middle' }} /> + </DropdownTrigger> + + <DropdownContent> + <EmojiPicker emojione={settings} onChange={this.handleChange} /> + </DropdownContent> + </Dropdown> + ); + } + +}); + +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 53129af6e..a67adbdd6 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 f8433b8f4..48b4a6b8e 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 }) => { <div className='static-content getting-started'> <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='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.' /></p> - <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> - <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p> </div> </div> </Column> 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 0b7c737c6..d75149a0e 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 0da3544f6..74b914ffd 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 ce4eacc92..b2342abbd 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 cc4d5cca4..2acf94274 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 8a7c0c5d5..caa46ff3c 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 = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />; } else { media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } 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 d8301b20f..e3c4281b9 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 = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; } - return ( - <Lightbox {...other}> - {leftNav} - + if (attachment.get('type') === 'image') { + content = ( <ImageLoader src={url} preloader={preloader} imgProps={{ style: imageStyle }} /> + ); + } else if (attachment.get('type') === 'gifv') { + content = <ExtendedVideoPlayer src={url} />; + } + return ( + <Lightbox {...other}> + {leftNav} + {content} {rightNav} </Lightbox> ); diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx index eaa6221e4..992e63727 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 f1d6a6dbc..3131dca1a 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 000000000..200efa3d7 --- /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 dead5fd77..b0001351f 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 e4af1f028..591f8034b 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 6472ac6a0..c67d05423 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 000000000..8f14bb338 --- /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 ad0427b52..a92d756f5 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 5fc67d9c1..4b1e86aca 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 3b2e88f6d..b9a9a1da3 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 94dba1d03..9c84e0a1b 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 000000000..42a9ed412 --- /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 c2002cb79..db16f82e5 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 dde7ce8c6..9c896fb09 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 b7479bf8c..60400e465 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 15601a079..a26e912a3 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 623a1af03..3a26c5c05 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 e353c3504..b58952ae0 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 a1b084682..bf4c16e43 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 a93a0668a..078078945 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 6925f9b0d..818190214 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 000000000..a5b334c85 --- /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 3796253d4..31e1ee198 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 1b40897f3..663ac1e34 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 77a73cce8..0d2fe43b8 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 08aac2679..bf2916d90 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 000000000..0050cfc8d --- /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 5d952df6f..69911abc5 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 d3d3af8af..aa0a4d71b 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 000000000..6aeea358f --- /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 22b37586e..d6f1dd48a 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 0b42e6e3d..916217cbd 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 000000000..9f3b13a53 --- /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 ae52173b5..21bf444c3 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 b089a7b73..85a0136b7 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 000000000..95aed6793 --- /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 b2e1e3e9e..99852592f 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 4b2ec142c..af41a3080 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 c0d38ec67..49087a575 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 31a294bb9..c0d4be1d8 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 7af8052ca..c32b48650 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 aee0540d2..a17279b1e 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 235dc6086..8c0456b1f 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 000000000..cd7faa700 --- /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 95f90abd9..cb2c976ce 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 000000000..dedb21e4e --- /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 |