Merge branch 'master' into mastodon-site-api
This commit is contained in:
commit
e245115f47
109 changed files with 1572 additions and 358 deletions
BIN
app/assets/images/mastodon-not-found.png
Normal file
BIN
app/assets/images/mastodon-not-found.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
|
@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
|
||||||
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
|
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
|
||||||
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
|
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_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
|
||||||
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
|
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
|
||||||
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
|
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) {
|
export function fetchAccount(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchRelationships([id]));
|
||||||
|
|
||||||
|
if (getState().getIn(['accounts', id], null) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(fetchAccountRequest(id));
|
dispatch(fetchAccountRequest(id));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||||
dispatch(fetchAccountSuccess(response.data));
|
dispatch(fetchAccountSuccess(response.data));
|
||||||
dispatch(fetchRelationships([id]));
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountFail(id, 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) {
|
export function fetchFollowers(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchFollowersRequest(id));
|
dispatch(fetchFollowersRequest(id));
|
||||||
|
|
|
@ -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_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
||||||
|
|
||||||
|
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
|
||||||
|
|
||||||
export function changeCompose(text) {
|
export function changeCompose(text) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_CHANGE,
|
type: COMPOSE_CHANGE,
|
||||||
|
@ -85,8 +87,13 @@ export function submitCompose() {
|
||||||
dispatch(updateTimeline('home', { ...response.data }));
|
dispatch(updateTimeline('home', { ...response.data }));
|
||||||
|
|
||||||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
||||||
dispatch(updateTimeline('community', { ...response.data }));
|
if (getState().getIn(['timelines', 'community', 'loaded'])) {
|
||||||
dispatch(updateTimeline('public', { ...response.data }));
|
dispatch(updateTimeline('community', { ...response.data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getState().getIn(['timelines', 'public', 'loaded'])) {
|
||||||
|
dispatch(updateTimeline('public', { ...response.data }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(submitComposeFail(error));
|
dispatch(submitComposeFail(error));
|
||||||
|
@ -255,3 +262,11 @@ export function changeComposeListability(checked) {
|
||||||
checked
|
checked
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function insertEmojiCompose(position, emoji) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_EMOJI_INSERT,
|
||||||
|
position,
|
||||||
|
emoji
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -106,18 +106,20 @@ export function expandTimeline(timeline) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = getState().getIn(['timelines', timeline, 'next']);
|
if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
|
||||||
const params = getState().getIn(['timelines', timeline, 'params'], {});
|
|
||||||
|
|
||||||
if (next === null) {
|
|
||||||
return;
|
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));
|
dispatch(expandTimelineRequest(timeline));
|
||||||
|
|
||||||
api(getState).get(next, {
|
api(getState).get(path, {
|
||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
|
max_id: lastId,
|
||||||
limit: 10
|
limit: 10
|
||||||
}
|
}
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { isRtl } from '../rtl';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
let word;
|
let word;
|
||||||
|
@ -39,7 +40,8 @@ const AutosuggestTextarea = React.createClass({
|
||||||
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
|
||||||
onChange: React.PropTypes.func.isRequired,
|
onChange: React.PropTypes.func.isRequired,
|
||||||
onKeyUp: React.PropTypes.func,
|
onKeyUp: React.PropTypes.func,
|
||||||
onKeyDown: React.PropTypes.func
|
onKeyDown: React.PropTypes.func,
|
||||||
|
onPaste: React.PropTypes.func.isRequired,
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState () {
|
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 () {
|
render () {
|
||||||
const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
|
const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
|
||||||
const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
|
const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
|
||||||
const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
|
const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
|
||||||
|
const style = { direction: 'ltr' };
|
||||||
|
|
||||||
|
if (isRtl(value)) {
|
||||||
|
style.direction = 'rtl';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-textarea'>
|
<div className='autosuggest-textarea'>
|
||||||
|
@ -192,6 +206,8 @@ const AutosuggestTextarea = React.createClass({
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onDragEnter={this.onDragEnter}
|
onDragEnter={this.onDragEnter}
|
||||||
onDragExit={this.onDragExit}
|
onDragExit={this.onDragExit}
|
||||||
|
onPaste={this.onPaste}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
|
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
|
||||||
|
|
|
@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
handleClick () {
|
handleClick () {
|
||||||
this.context.router.goBack();
|
if (window.history && window.history.length == 1) this.context.router.push("/");
|
||||||
|
else this.context.router.goBack();
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
|
|
@ -10,12 +10,44 @@ const DropdownMenu = React.createClass({
|
||||||
direction: React.PropTypes.string
|
direction: React.PropTypes.string
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDefaultProps () {
|
||||||
|
return {
|
||||||
|
direction: 'left'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
setRef (c) {
|
setRef (c) {
|
||||||
this.dropdown = 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 () {
|
render () {
|
||||||
const { icon, items, size, direction } = this.props;
|
const { icon, items, size, direction } = this.props;
|
||||||
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
|
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
|
||||||
|
@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({
|
||||||
|
|
||||||
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
|
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
|
||||||
<ul>
|
<ul>
|
||||||
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
|
{items.map(this.renderItem)}
|
||||||
if (typeof action === 'function') {
|
|
||||||
e.preventDefault();
|
|
||||||
action();
|
|
||||||
this.dropdown.hide();
|
|
||||||
}
|
|
||||||
}}>{text}</a></li>)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</DropdownContent>
|
</DropdownContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
|
@ -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;
|
|
@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from './icon_button';
|
import IconButton from './icon_button';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { isIOS } from '../is_mobile';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
|
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
|
||||||
|
@ -43,6 +44,141 @@ const spoilerButtonStyle = {
|
||||||
zIndex: '100'
|
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({
|
const MediaGallery = React.createClass({
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
|
@ -61,17 +197,12 @@ const MediaGallery = React.createClass({
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
handleClick (index, e) {
|
handleOpen (e) {
|
||||||
if (e.button === 0) {
|
this.setState({ visible: !this.state.visible });
|
||||||
e.preventDefault();
|
|
||||||
this.props.onOpenMedia(this.props.media, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpen () {
|
handleClick (index) {
|
||||||
this.setState({ visible: !this.state.visible });
|
this.props.onOpenMedia(this.props.media, index);
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -80,87 +211,31 @@ const MediaGallery = React.createClass({
|
||||||
let children;
|
let children;
|
||||||
|
|
||||||
if (!this.state.visible) {
|
if (!this.state.visible) {
|
||||||
|
let warning;
|
||||||
|
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
children = (
|
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
children = (
|
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
|
<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} />
|
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,13 +6,13 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
mention: { id: 'status.mention', defaultMessage: 'Mention' },
|
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||||
block: { id: 'account.block', defaultMessage: 'Block' },
|
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||||
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||||
open: { id: 'status.open', defaultMessage: 'Expand' },
|
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||||
report: { id: 'status.report', defaultMessage: 'Report' }
|
report: { id: 'status.report', defaultMessage: 'Report @{name}' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const StatusActionBar = React.createClass({
|
const StatusActionBar = React.createClass({
|
||||||
|
@ -74,13 +74,15 @@ const StatusActionBar = React.createClass({
|
||||||
let menu = [];
|
let menu = [];
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||||
|
menu.push(null);
|
||||||
|
|
||||||
if (status.getIn(['account', 'id']) === me) {
|
if (status.getIn(['account', 'id']) === me) {
|
||||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||||
} else {
|
} else {
|
||||||
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
|
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||||
menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
|
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 (
|
return (
|
||||||
|
|
|
@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
import emojify from '../emoji';
|
import emojify from '../emoji';
|
||||||
|
import { isRtl } from '../rtl';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
|
|
||||||
|
@ -92,6 +93,11 @@ const StatusContent = React.createClass({
|
||||||
|
|
||||||
const content = { __html: emojify(status.get('content')) };
|
const content = { __html: emojify(status.get('content')) };
|
||||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
|
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) {
|
if (status.get('spoiler_text').length > 0) {
|
||||||
let mentionsPlaceholder = '';
|
let mentionsPlaceholder = '';
|
||||||
|
@ -116,14 +122,14 @@ const StatusContent = React.createClass({
|
||||||
|
|
||||||
{mentionsPlaceholder}
|
{mentionsPlaceholder}
|
||||||
|
|
||||||
<div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
|
<div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='status__content'
|
className='status__content'
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer', ...directionStyle }}
|
||||||
onMouseDown={this.handleMouseDown}
|
onMouseDown={this.handleMouseDown}
|
||||||
onMouseUp={this.handleMouseUp}
|
onMouseUp={this.handleMouseUp}
|
||||||
dangerouslySetInnerHTML={content}
|
dangerouslySetInnerHTML={content}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from './icon_button';
|
import IconButton from './icon_button';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { isIOS } from '../is_mobile';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
|
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
|
||||||
|
@ -61,12 +62,14 @@ const VideoPlayer = React.createClass({
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
width: React.PropTypes.number,
|
width: React.PropTypes.number,
|
||||||
height: 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 () {
|
getDefaultProps () {
|
||||||
return {
|
return {
|
||||||
width: 196,
|
width: 239,
|
||||||
height: 110
|
height: 110
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -75,7 +78,8 @@ const VideoPlayer = React.createClass({
|
||||||
return {
|
return {
|
||||||
visible: !this.props.sensitive,
|
visible: !this.props.sensitive,
|
||||||
preview: true,
|
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 () {
|
render () {
|
||||||
const { media, intl, width, height, sensitive } = this.props;
|
const { media, intl, width, height, sensitive, autoplay } = this.props;
|
||||||
|
|
||||||
let spoilerButton = (
|
let spoilerButton = (
|
||||||
<div style={spoilerButtonStyle} >
|
<div style={spoilerButtonStyle} >
|
||||||
|
@ -117,6 +155,16 @@ const VideoPlayer = React.createClass({
|
||||||
</div>
|
</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 (!this.state.visible) {
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
return (
|
return (
|
||||||
|
@ -128,7 +176,7 @@ const VideoPlayer = React.createClass({
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
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}
|
{spoilerButton}
|
||||||
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
|
<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>
|
<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 (
|
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}>
|
<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}
|
{spoilerButton}
|
||||||
|
@ -149,8 +197,8 @@ const VideoPlayer = React.createClass({
|
||||||
return (
|
return (
|
||||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||||
{spoilerButton}
|
{spoilerButton}
|
||||||
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
|
{muteButton}
|
||||||
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
<video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,9 @@ import {
|
||||||
followAccount,
|
followAccount,
|
||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
blockAccount,
|
blockAccount,
|
||||||
unblockAccount
|
unblockAccount,
|
||||||
|
muteAccount,
|
||||||
|
unmuteAccount,
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
|
@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
} else {
|
} else {
|
||||||
dispatch(blockAccount(account.get('id')));
|
dispatch(blockAccount(account.get('id')));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onMute (account) {
|
||||||
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(muteAccount(account.get('id')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,10 @@ import {
|
||||||
unreblog,
|
unreblog,
|
||||||
unfavourite
|
unfavourite
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import { blockAccount } from '../actions/accounts';
|
import {
|
||||||
|
blockAccount,
|
||||||
|
muteAccount
|
||||||
|
} from '../actions/accounts';
|
||||||
import { deleteStatus } from '../actions/statuses';
|
import { deleteStatus } from '../actions/statuses';
|
||||||
import { initReport } from '../actions/reports';
|
import { initReport } from '../actions/reports';
|
||||||
import { openMedia } from '../actions/modal';
|
import { openMedia } from '../actions/modal';
|
||||||
|
@ -69,7 +72,11 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
|
|
||||||
onReport (status) {
|
onReport (status) {
|
||||||
dispatch(initReport(status.get('account'), status));
|
dispatch(initReport(status.get('account'), status));
|
||||||
}
|
},
|
||||||
|
|
||||||
|
onMute (account) {
|
||||||
|
dispatch(muteAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,14 +5,16 @@ import { Link } from 'react-router';
|
||||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
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' },
|
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' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
block: { id: 'account.block', defaultMessage: 'Block' },
|
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||||
report: { id: 'account.report', defaultMessage: 'Report' }
|
disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const outerDropdownStyle = {
|
const outerDropdownStyle = {
|
||||||
|
@ -35,6 +37,7 @@ const ActionBar = React.createClass({
|
||||||
onBlock: React.PropTypes.func.isRequired,
|
onBlock: React.PropTypes.func.isRequired,
|
||||||
onMention: React.PropTypes.func.isRequired,
|
onMention: React.PropTypes.func.isRequired,
|
||||||
onReport: React.PropTypes.func.isRequired,
|
onReport: React.PropTypes.func.isRequired,
|
||||||
|
onMute: React.PropTypes.func.isRequired,
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -44,21 +47,31 @@ const ActionBar = React.createClass({
|
||||||
const { account, me, intl } = this.props;
|
const { account, me, intl } = this.props;
|
||||||
|
|
||||||
let menu = [];
|
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) {
|
if (account.get('id') === me) {
|
||||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
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 {
|
} 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) {
|
if (account.get('acct') !== account.get('username')) {
|
||||||
menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
|
extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -70,17 +83,17 @@ const ActionBar = React.createClass({
|
||||||
<div style={outerLinksStyle}>
|
<div style={outerLinksStyle}>
|
||||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
|
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
|
||||||
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
|
<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>
|
||||||
|
|
||||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
|
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
|
||||||
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
|
<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>
|
||||||
|
|
||||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
|
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
|
||||||
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,7 +15,8 @@ const Header = React.createClass({
|
||||||
onFollow: React.PropTypes.func.isRequired,
|
onFollow: React.PropTypes.func.isRequired,
|
||||||
onBlock: React.PropTypes.func.isRequired,
|
onBlock: React.PropTypes.func.isRequired,
|
||||||
onMention: 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],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -37,6 +38,10 @@ const Header = React.createClass({
|
||||||
this.context.router.push('/report');
|
this.context.router.push('/report');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMute() {
|
||||||
|
this.props.onMute(this.props.account);
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, me } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
|
@ -58,6 +63,7 @@ const Header = React.createClass({
|
||||||
onBlock={this.handleBlock}
|
onBlock={this.handleBlock}
|
||||||
onMention={this.handleMention}
|
onMention={this.handleMention}
|
||||||
onReport={this.handleReport}
|
onReport={this.handleReport}
|
||||||
|
onMute={this.handleMute}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,9 @@ import {
|
||||||
followAccount,
|
followAccount,
|
||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
blockAccount,
|
blockAccount,
|
||||||
unblockAccount
|
unblockAccount,
|
||||||
|
muteAccount,
|
||||||
|
unmuteAccount
|
||||||
} from '../../../actions/accounts';
|
} from '../../../actions/accounts';
|
||||||
import { mentionCompose } from '../../../actions/compose';
|
import { mentionCompose } from '../../../actions/compose';
|
||||||
import { initReport } from '../../../actions/reports';
|
import { initReport } from '../../../actions/reports';
|
||||||
|
@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
onReport (account) {
|
onReport (account) {
|
||||||
dispatch(initReport(account));
|
dispatch(initReport(account));
|
||||||
|
},
|
||||||
|
|
||||||
|
onMute (account) {
|
||||||
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(muteAccount(account.get('id')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ const mapStateToProps = state => ({
|
||||||
accessToken: state.getIn(['meta', 'access_token'])
|
accessToken: state.getIn(['meta', 'access_token'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let subscription;
|
||||||
|
|
||||||
const CommunityTimeline = React.createClass({
|
const CommunityTimeline = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
|
@ -36,7 +38,11 @@ const CommunityTimeline = React.createClass({
|
||||||
|
|
||||||
dispatch(refreshTimeline('community'));
|
dispatch(refreshTimeline('community'));
|
||||||
|
|
||||||
this.subscription = createStream(accessToken, 'public:local', {
|
if (typeof subscription !== 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription = createStream(accessToken, 'public:local', {
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
|
@ -53,10 +59,10 @@ const CommunityTimeline = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
if (typeof this.subscription !== 'undefined') {
|
// if (typeof subscription !== 'undefined') {
|
||||||
this.subscription.close();
|
// subscription.close();
|
||||||
this.subscription = null;
|
// subscription = null;
|
||||||
}
|
// }
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
|
|
@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
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 (
|
return (
|
||||||
<span style={{ fontSize: '16px', cursor: 'default' }}>
|
<span style={{ fontSize: '16px', cursor: 'default' }}>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
|
||||||
import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
|
import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
|
||||||
import PrivateToggleContainer from '../containers/private_toggle_container';
|
import PrivateToggleContainer from '../containers/private_toggle_container';
|
||||||
import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
|
import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
|
||||||
|
import EmojiPickerDropdown from './emoji_picker_dropdown';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||||
|
@ -47,6 +48,8 @@ const ComposeForm = React.createClass({
|
||||||
onFetchSuggestions: React.PropTypes.func.isRequired,
|
onFetchSuggestions: React.PropTypes.func.isRequired,
|
||||||
onSuggestionSelected: React.PropTypes.func.isRequired,
|
onSuggestionSelected: React.PropTypes.func.isRequired,
|
||||||
onChangeSpoilerText: React.PropTypes.func.isRequired,
|
onChangeSpoilerText: React.PropTypes.func.isRequired,
|
||||||
|
onPaste: React.PropTypes.func.isRequired,
|
||||||
|
onPickEmoji: React.PropTypes.func.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -75,6 +78,7 @@ const ComposeForm = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuggestionSelected (tokenStart, token, value) {
|
onSuggestionSelected (tokenStart, token, value) {
|
||||||
|
this._restoreCaret = null;
|
||||||
this.props.onSuggestionSelected(tokenStart, token, value);
|
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 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;
|
// 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.
|
// this provides a convenient shortcut to drop everyone else from the conversation.
|
||||||
const selectionEnd = this.props.text.length;
|
let selectionEnd, selectionStart;
|
||||||
const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd;
|
|
||||||
|
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.setSelectionRange(selectionStart, selectionEnd);
|
||||||
this.autosuggestTextarea.textarea.focus();
|
this.autosuggestTextarea.textarea.focus();
|
||||||
|
@ -99,8 +113,14 @@ const ComposeForm = React.createClass({
|
||||||
this.autosuggestTextarea = c;
|
this.autosuggestTextarea = c;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleEmojiPick (data) {
|
||||||
|
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||||
|
this._restoreCaret = position + data.shortname.length + 1;
|
||||||
|
this.props.onPickEmoji(position, data);
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
|
const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
|
||||||
const disabled = this.props.is_submitting || this.props.is_uploading;
|
const disabled = this.props.is_submitting || this.props.is_uploading;
|
||||||
|
|
||||||
let publishText = '';
|
let publishText = '';
|
||||||
|
@ -149,12 +169,16 @@ const ComposeForm = React.createClass({
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
|
onPaste={onPaste}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||||
<div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<SpoilerToggleContainer />
|
<SpoilerToggleContainer />
|
||||||
|
|
|
@ -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);
|
|
@ -1,5 +1,6 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
|
import { uploadCompose } from '../../../actions/compose';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import {
|
import {
|
||||||
changeCompose,
|
changeCompose,
|
||||||
|
@ -8,6 +9,7 @@ import {
|
||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
selectComposeSuggestion,
|
selectComposeSuggestion,
|
||||||
changeComposeSpoilerText,
|
changeComposeSpoilerText,
|
||||||
|
insertEmojiCompose
|
||||||
} from '../../../actions/compose';
|
} from '../../../actions/compose';
|
||||||
|
|
||||||
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
|
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));
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onPaste (files) {
|
||||||
|
dispatch(uploadCompose(files));
|
||||||
|
},
|
||||||
|
|
||||||
|
onPickEmoji (position, data) {
|
||||||
|
dispatch(insertEmojiCompose(position, data));
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
|
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
|
||||||
|
|
|
@ -45,8 +45,7 @@ const GettingStarted = ({ intl, me }) => {
|
||||||
<div className='static-content getting-started'>
|
<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_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_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}. Various apps are available.' 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}' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -4,7 +4,8 @@ const iconStyle = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '48px',
|
right: '48px',
|
||||||
top: '0',
|
top: '0',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
|
zIndex: '2'
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClearColumnButton = ({ onClick }) => (
|
const ClearColumnButton = ({ onClick }) => (
|
||||||
|
|
|
@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
|
||||||
import ClearColumnButton from './components/clear_column_button';
|
import ClearColumnButton from './components/clear_column_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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([
|
const getNotifications = createSelector([
|
||||||
|
@ -72,7 +73,9 @@ const Notifications = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
handleClear () {
|
handleClear () {
|
||||||
this.props.dispatch(clearNotifications());
|
if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
|
||||||
|
this.props.dispatch(clearNotifications());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setRef (c) {
|
setRef (c) {
|
||||||
|
|
|
@ -20,6 +20,8 @@ const mapStateToProps = state => ({
|
||||||
accessToken: state.getIn(['meta', 'access_token'])
|
accessToken: state.getIn(['meta', 'access_token'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let subscription;
|
||||||
|
|
||||||
const PublicTimeline = React.createClass({
|
const PublicTimeline = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
|
@ -36,7 +38,11 @@ const PublicTimeline = React.createClass({
|
||||||
|
|
||||||
dispatch(refreshTimeline('public'));
|
dispatch(refreshTimeline('public'));
|
||||||
|
|
||||||
this.subscription = createStream(accessToken, 'public', {
|
if (typeof subscription !== 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription = createStream(accessToken, 'public', {
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
|
@ -53,10 +59,10 @@ const PublicTimeline = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
if (typeof this.subscription !== 'undefined') {
|
// if (typeof subscription !== 'undefined') {
|
||||||
this.subscription.close();
|
// subscription.close();
|
||||||
this.subscription = null;
|
// subscription = null;
|
||||||
}
|
// }
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
|
|
@ -6,11 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
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' },
|
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||||
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||||
report: { id: 'status.report', defaultMessage: 'Report' }
|
report: { id: 'status.report', defaultMessage: 'Report @{name}' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const ActionBar = React.createClass({
|
const ActionBar = React.createClass({
|
||||||
|
@ -66,8 +66,9 @@ const ActionBar = React.createClass({
|
||||||
if (me === status.getIn(['account', 'id'])) {
|
if (me === status.getIn(['account', 'id'])) {
|
||||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||||
} else {
|
} else {
|
||||||
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
|
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||||
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
|
menu.push(null);
|
||||||
|
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0) {
|
if (status.get('media_attachments').size > 0) {
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
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 {
|
} else {
|
||||||
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
|
||||||
import LoadingIndicator from '../../../components/loading_indicator';
|
import LoadingIndicator from '../../../components/loading_indicator';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ExtendedVideoPlayer from '../../../components/extended_video_player';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
media: state.getIn(['modal', 'media']),
|
media: state.getIn(['modal', 'media']),
|
||||||
|
@ -131,27 +132,34 @@ const Modal = React.createClass({
|
||||||
return null;
|
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) {
|
if (media.size > 1) {
|
||||||
leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
|
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>;
|
rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (attachment.get('type') === 'image') {
|
||||||
<Lightbox {...other}>
|
content = (
|
||||||
{leftNav}
|
|
||||||
|
|
||||||
<ImageLoader
|
<ImageLoader
|
||||||
src={url}
|
src={url}
|
||||||
preloader={preloader}
|
preloader={preloader}
|
||||||
imgProps={{ style: imageStyle }}
|
imgProps={{ style: imageStyle }}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.get('type') === 'gifv') {
|
||||||
|
content = <ExtendedVideoPlayer src={url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Lightbox {...other}>
|
||||||
|
{leftNav}
|
||||||
|
{content}
|
||||||
{rightNav}
|
{rightNav}
|
||||||
</Lightbox>
|
</Lightbox>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
|
||||||
export function isMobile(width) {
|
export function isMobile(width) {
|
||||||
return width <= LAYOUT_BREAKPOINT;
|
return width <= LAYOUT_BREAKPOINT;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
|
||||||
|
export function isIOS() {
|
||||||
|
return iOS;
|
||||||
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@ const en = {
|
||||||
"column_back_button.label": "Back",
|
"column_back_button.label": "Back",
|
||||||
"lightbox.close": "Close",
|
"lightbox.close": "Close",
|
||||||
"loading_indicator.label": "Loading...",
|
"loading_indicator.label": "Loading...",
|
||||||
"status.mention": "Mention",
|
"status.mention": "Mention @{name}",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
"status.reply": "Reply",
|
"status.reply": "Reply",
|
||||||
"status.reblog": "Boost",
|
"status.reblog": "Boost",
|
||||||
|
@ -11,11 +11,11 @@ const en = {
|
||||||
"status.sensitive_warning": "Sensitive content",
|
"status.sensitive_warning": "Sensitive content",
|
||||||
"status.sensitive_toggle": "Click to view",
|
"status.sensitive_toggle": "Click to view",
|
||||||
"video_player.toggle_sound": "Toggle sound",
|
"video_player.toggle_sound": "Toggle sound",
|
||||||
"account.mention": "Mention",
|
"account.mention": "Mention @{name}",
|
||||||
"account.edit_profile": "Edit profile",
|
"account.edit_profile": "Edit profile",
|
||||||
"account.unblock": "Unblock",
|
"account.unblock": "Unblock @{name}",
|
||||||
"account.unfollow": "Unfollow",
|
"account.unfollow": "Unfollow",
|
||||||
"account.block": "Block",
|
"account.block": "Block @{name}",
|
||||||
"account.follow": "Follow",
|
"account.follow": "Follow",
|
||||||
"account.posts": "Posts",
|
"account.posts": "Posts",
|
||||||
"account.follows": "Follows",
|
"account.follows": "Follows",
|
||||||
|
@ -25,16 +25,15 @@ const en = {
|
||||||
"getting_started.heading": "Getting started",
|
"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_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_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}. Various apps are available.",
|
||||||
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
|
|
||||||
"column.home": "Home",
|
"column.home": "Home",
|
||||||
"column.community": "Local",
|
"column.community": "Local timeline",
|
||||||
"column.public": "Whole Known Network",
|
"column.public": "Federated timeline",
|
||||||
"column.notifications": "Notifications",
|
"column.notifications": "Notifications",
|
||||||
"tabs_bar.compose": "Compose",
|
"tabs_bar.compose": "Compose",
|
||||||
"tabs_bar.home": "Home",
|
"tabs_bar.home": "Home",
|
||||||
"tabs_bar.mentions": "Mentions",
|
"tabs_bar.mentions": "Mentions",
|
||||||
"tabs_bar.public": "Whole Known Network",
|
"tabs_bar.public": "Federated timeline",
|
||||||
"tabs_bar.notifications": "Notifications",
|
"tabs_bar.notifications": "Notifications",
|
||||||
"compose_form.placeholder": "What is on your mind?",
|
"compose_form.placeholder": "What is on your mind?",
|
||||||
"compose_form.publish": "Toot",
|
"compose_form.publish": "Toot",
|
||||||
|
@ -46,7 +45,7 @@ const en = {
|
||||||
"navigation_bar.edit_profile": "Edit profile",
|
"navigation_bar.edit_profile": "Edit profile",
|
||||||
"navigation_bar.preferences": "Preferences",
|
"navigation_bar.preferences": "Preferences",
|
||||||
"navigation_bar.community_timeline": "Local timeline",
|
"navigation_bar.community_timeline": "Local timeline",
|
||||||
"navigation_bar.public_timeline": "Whole Known Network",
|
"navigation_bar.public_timeline": "Federated timeline",
|
||||||
"navigation_bar.logout": "Logout",
|
"navigation_bar.logout": "Logout",
|
||||||
"reply_indicator.cancel": "Cancel",
|
"reply_indicator.cancel": "Cancel",
|
||||||
"search.placeholder": "Search",
|
"search.placeholder": "Search",
|
||||||
|
|
22
app/assets/javascripts/components/middleware/sounds.jsx
Normal file
22
app/assets/javascripts/components/middleware/sounds.jsx
Normal file
|
@ -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);
|
||||||
|
};
|
||||||
|
};
|
|
@ -20,7 +20,8 @@ import {
|
||||||
COMPOSE_SPOILERNESS_CHANGE,
|
COMPOSE_SPOILERNESS_CHANGE,
|
||||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||||
COMPOSE_VISIBILITY_CHANGE,
|
COMPOSE_VISIBILITY_CHANGE,
|
||||||
COMPOSE_LISTABILITY_CHANGE
|
COMPOSE_LISTABILITY_CHANGE,
|
||||||
|
COMPOSE_EMOJI_INSERT
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { STORE_HYDRATE } from '../actions/store';
|
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) {
|
export default function compose(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
|
@ -177,6 +187,8 @@ export default function compose(state = initialState, action) {
|
||||||
} else {
|
} else {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
case COMPOSE_EMOJI_INSERT:
|
||||||
|
return insertEmoji(state, action.position, action.emoji);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ import {
|
||||||
ACCOUNT_UNFOLLOW_SUCCESS,
|
ACCOUNT_UNFOLLOW_SUCCESS,
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
ACCOUNT_UNBLOCK_SUCCESS,
|
ACCOUNT_UNBLOCK_SUCCESS,
|
||||||
|
ACCOUNT_MUTE_SUCCESS,
|
||||||
|
ACCOUNT_UNMUTE_SUCCESS,
|
||||||
RELATIONSHIPS_FETCH_SUCCESS
|
RELATIONSHIPS_FETCH_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
@ -25,6 +27,8 @@ export default function relationships(state = initialState, action) {
|
||||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
case ACCOUNT_UNFOLLOW_SUCCESS:
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
case ACCOUNT_UNBLOCK_SUCCESS:
|
case ACCOUNT_UNBLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
case ACCOUNT_UNMUTE_SUCCESS:
|
||||||
return normalizeRelationship(state, action.relationship);
|
return normalizeRelationship(state, action.relationship);
|
||||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||||
return normalizeRelationships(state, action.relationships);
|
return normalizeRelationships(state, action.relationships);
|
||||||
|
|
|
@ -22,7 +22,8 @@ import {
|
||||||
ACCOUNT_TIMELINE_EXPAND_REQUEST,
|
ACCOUNT_TIMELINE_EXPAND_REQUEST,
|
||||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_EXPAND_FAIL,
|
ACCOUNT_TIMELINE_EXPAND_FAIL,
|
||||||
ACCOUNT_BLOCK_SUCCESS
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
|
ACCOUNT_MUTE_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import {
|
import {
|
||||||
CONTEXT_FETCH_SUCCESS
|
CONTEXT_FETCH_SUCCESS
|
||||||
|
@ -295,6 +296,7 @@ export default function timelines(state = initialState, action) {
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
return filterTimelines(state, action.relationship, action.statuses);
|
return filterTimelines(state, action.relationship, action.statuses);
|
||||||
case TIMELINE_SCROLL_TOP:
|
case TIMELINE_SCROLL_TOP:
|
||||||
return updateTop(state, action.timeline, action.top);
|
return updateTop(state, action.timeline, action.top);
|
||||||
|
|
27
app/assets/javascripts/components/rtl.jsx
Normal file
27
app/assets/javascripts/components/rtl.jsx
Normal file
|
@ -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;
|
||||||
|
};
|
|
@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
|
||||||
import appReducer from '../reducers';
|
import appReducer from '../reducers';
|
||||||
import loadingBarMiddleware from '../middleware/loading_bar';
|
import loadingBarMiddleware from '../middleware/loading_bar';
|
||||||
import errorsMiddleware from '../middleware/errors';
|
import errorsMiddleware from '../middleware/errors';
|
||||||
import soundsMiddleware from 'redux-sounds';
|
import soundsMiddleware from '../middleware/sounds';
|
||||||
import Howler from 'howler';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
Howler.mobileAutoEnable = false;
|
|
||||||
|
|
||||||
const soundsData = {
|
|
||||||
boop: '/sounds/boop.mp3'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function configureStore() {
|
export default function configureStore() {
|
||||||
return createStore(appReducer, compose(applyMiddleware(
|
return createStore(appReducer, compose(applyMiddleware(
|
||||||
thunk,
|
thunk,
|
||||||
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
|
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
|
||||||
errorsMiddleware(),
|
errorsMiddleware(),
|
||||||
soundsMiddleware(soundsData)
|
soundsMiddleware()
|
||||||
), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
), window.devToolsExtension ? window.devToolsExtension() : f => f));
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import 'variables';
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
background-color: darken($color4, 3%);
|
background-color: darken($color4, 3%);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
@ -59,6 +61,14 @@
|
||||||
&.active {
|
&.active {
|
||||||
color: $color4;
|
color: $color4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown--active .icon-button {
|
||||||
|
color: $color4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invisible {
|
.invisible {
|
||||||
|
@ -387,6 +397,10 @@ a.status__content__spoiler-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $color5;
|
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 {
|
.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;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown__sep {
|
||||||
|
border-bottom: 1px solid darken($color2, 8%);
|
||||||
|
margin: 5px 7px 6px;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown--active .dropdown__content {
|
.dropdown--active .dropdown__content {
|
||||||
display: block;
|
display: block;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
@ -533,23 +553,40 @@ a.status__content__spoiler-link {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
& > ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background: $color2;
|
background: $color2;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 0 15px rgba($color8, 0.4);
|
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;
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 16px;
|
padding: 4px 14px;
|
||||||
width: 100px;
|
box-sizing: border-box;
|
||||||
|
width: 140px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: $color2;
|
background: $color2;
|
||||||
color: $color1;
|
color: $color1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color4;
|
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 {
|
.setting-text {
|
||||||
color: $color3;
|
color: $color3;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
@ -1074,8 +1102,10 @@ button.active i.fa-retweet {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: lighten($color1, 26%);
|
color: lighten($color1, 16%);
|
||||||
padding-top: 120px;
|
padding-top: 210px;
|
||||||
|
background: image-url('mastodon-not-found.png') no-repeat center -50px;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-header {
|
.column-header {
|
||||||
|
@ -1230,3 +1260,164 @@ button.active i.fa-retweet {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -104,8 +104,12 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 110px;
|
position: relative;
|
||||||
display: flex;
|
|
||||||
|
.status__attachments__inner {
|
||||||
|
display: flex;
|
||||||
|
height: 214px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,8 +188,12 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 300px;
|
position: relative;
|
||||||
display: flex;
|
|
||||||
|
.status__attachments__inner {
|
||||||
|
display: flex;
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player {
|
.video-player {
|
||||||
|
@ -231,11 +239,19 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-item {
|
.video-item {
|
||||||
max-width: 196px;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -258,6 +274,9 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::AccountsController < ApiController
|
class Api::V1::AccountsController < ApiController
|
||||||
before_action -> { doorkeeper_authorize! :read }, except: [: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]
|
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
|
||||||
before_action :require_user!, except: [:show, :following, :followers, :statuses]
|
before_action :require_user!, except: [:show, :following, :followers, :statuses]
|
||||||
before_action :set_account, except: [:verify_credentials, :suggestions, :search]
|
before_action :set_account, except: [:verify_credentials, :suggestions, :search]
|
||||||
|
|
||||||
|
@ -47,10 +47,13 @@ class Api::V1::AccountsController < ApiController
|
||||||
|
|
||||||
def statuses
|
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 = @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)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
||||||
set_maps(@statuses)
|
set_maps(@statuses)
|
||||||
set_counters_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?
|
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?
|
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)
|
set_pagination_headers(next_path, prev_path)
|
||||||
end
|
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
|
def follow
|
||||||
FollowService.new.call(current_user.account, @account.acct)
|
FollowService.new.call(current_user.account, @account.acct)
|
||||||
set_relationship
|
set_relationship
|
||||||
|
@ -86,10 +74,17 @@ class Api::V1::AccountsController < ApiController
|
||||||
@followed_by = { @account.id => false }
|
@followed_by = { @account.id => false }
|
||||||
@blocking = { @account.id => true }
|
@blocking = { @account.id => true }
|
||||||
@requested = { @account.id => false }
|
@requested = { @account.id => false }
|
||||||
|
@muting = { @account.id => current_user.account.muting?(@account.id) }
|
||||||
|
|
||||||
render action: :relationship
|
render action: :relationship
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mute
|
||||||
|
MuteService.new.call(current_user.account, @account)
|
||||||
|
set_relationship
|
||||||
|
render action: :relationship
|
||||||
|
end
|
||||||
|
|
||||||
def unfollow
|
def unfollow
|
||||||
UnfollowService.new.call(current_user.account, @account)
|
UnfollowService.new.call(current_user.account, @account)
|
||||||
set_relationship
|
set_relationship
|
||||||
|
@ -102,6 +97,12 @@ class Api::V1::AccountsController < ApiController
|
||||||
render action: :relationship
|
render action: :relationship
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unmute
|
||||||
|
UnmuteService.new.call(current_user.account, @account)
|
||||||
|
set_relationship
|
||||||
|
render action: :relationship
|
||||||
|
end
|
||||||
|
|
||||||
def relationships
|
def relationships
|
||||||
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
|
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)
|
@following = Account.following_map(ids, current_user.account_id)
|
||||||
@followed_by = Account.followed_by_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)
|
@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)
|
@requested = Account.requested_map(ids, current_user.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -130,6 +132,7 @@ class Api::V1::AccountsController < ApiController
|
||||||
@following = Account.following_map([@account.id], current_user.account_id)
|
@following = Account.following_map([@account.id], current_user.account_id)
|
||||||
@followed_by = Account.followed_by_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)
|
@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)
|
@requested = Account.requested_map([@account.id], current_user.account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
21
app/controllers/api/v1/mutes_controller.rb
Normal file
21
app/controllers/api/v1/mutes_controller.rb
Normal file
|
@ -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
|
|
@ -79,6 +79,7 @@ class ApiController < ApplicationController
|
||||||
|
|
||||||
def require_user!
|
def require_user!
|
||||||
current_resource_owner
|
current_resource_owner
|
||||||
|
set_user_activity
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: 'This method requires an authenticated user' }, status: 422
|
render json: { error: 'This method requires an authenticated user' }, status: 422
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,6 +13,10 @@ module ObfuscateFilename
|
||||||
file = params.dig(*path)
|
file = params.dig(*path)
|
||||||
return if file.nil?
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController
|
||||||
reblog: user_params[:notification_emails][:reblog] == '1',
|
reblog: user_params[:notification_emails][:reblog] == '1',
|
||||||
favourite: user_params[:notification_emails][:favourite] == '1',
|
favourite: user_params[:notification_emails][:favourite] == '1',
|
||||||
mention: user_params[:notification_emails][:mention] == '1',
|
mention: user_params[:notification_emails][:mention] == '1',
|
||||||
|
digest: user_params[:notification_emails][:digest] == '1',
|
||||||
}
|
}
|
||||||
|
|
||||||
current_user.settings['interactions'] = {
|
current_user.settings['interactions'] = {
|
||||||
|
@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def user_params
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -37,4 +37,17 @@ module StreamEntriesHelper
|
||||||
def proper_status(status)
|
def proper_status(status)
|
||||||
status.reblog? ? status.reblog : status
|
status.reblog? ? status.reblog : status
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -22,8 +22,18 @@ class FeedManager
|
||||||
end
|
end
|
||||||
|
|
||||||
def push(timeline_type, account, status)
|
def push(timeline_type, account, status)
|
||||||
redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
|
timeline_key = key(timeline_type, account.id)
|
||||||
trim(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))
|
broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -85,6 +95,8 @@ class FeedManager
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_from_home?(status, receiver)
|
def filter_from_home?(status, receiver)
|
||||||
|
return true if receiver.muting?(status.account)
|
||||||
|
|
||||||
should_filter = false
|
should_filter = false
|
||||||
|
|
||||||
if status.reply? && status.in_reply_to_id.nil?
|
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
|
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
|
elsif status.reblog? # Filter out a reblog
|
||||||
should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person
|
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
|
end
|
||||||
|
|
||||||
should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
|
should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
|
||||||
|
|
|
@ -29,6 +29,11 @@ class Formatter
|
||||||
sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
|
sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def plaintext(status)
|
||||||
|
return status.text if status.local?
|
||||||
|
strip_tags(status.text)
|
||||||
|
end
|
||||||
|
|
||||||
def simplified_format(account)
|
def simplified_format(account)
|
||||||
return reformat(account.note) unless account.local?
|
return reformat(account.note) unless account.local?
|
||||||
|
|
||||||
|
|
|
@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer
|
||||||
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
|
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ class Account < ApplicationRecord
|
||||||
include Targetable
|
include Targetable
|
||||||
include PgSearch
|
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
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
|
|
||||||
# Local users
|
# Local users
|
||||||
|
@ -46,6 +46,10 @@ class Account < ApplicationRecord
|
||||||
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
|
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
|
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
|
# Media
|
||||||
has_many :media_attachments, dependent: :destroy
|
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)
|
block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mute!(other_account)
|
||||||
|
mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||||
|
end
|
||||||
|
|
||||||
def unfollow!(other_account)
|
def unfollow!(other_account)
|
||||||
follow = active_relationships.find_by(target_account: other_account)
|
follow = active_relationships.find_by(target_account: other_account)
|
||||||
follow&.destroy
|
follow&.destroy
|
||||||
|
@ -83,6 +91,11 @@ class Account < ApplicationRecord
|
||||||
block&.destroy
|
block&.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unmute!(other_account)
|
||||||
|
mute = mute_relationships.find_by(target_account: other_account)
|
||||||
|
mute&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
def following?(other_account)
|
def following?(other_account)
|
||||||
following.include?(other_account)
|
following.include?(other_account)
|
||||||
end
|
end
|
||||||
|
@ -91,6 +104,10 @@ class Account < ApplicationRecord
|
||||||
blocking.include?(other_account)
|
blocking.include?(other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def muting?(other_account)
|
||||||
|
muting.include?(other_account)
|
||||||
|
end
|
||||||
|
|
||||||
def requested?(other_account)
|
def requested?(other_account)
|
||||||
follow_requests.where(target_account: other_account).exists?
|
follow_requests.where(target_account: other_account).exists?
|
||||||
end
|
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)
|
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
|
||||||
end
|
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)
|
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)
|
follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,15 +1,32 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
|
self.inheritance_column = nil
|
||||||
|
|
||||||
|
enum type: [:image, :gifv, :video]
|
||||||
|
|
||||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].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 :account, inverse_of: :media_attachments
|
||||||
belongs_to :status, inverse_of: :media_attachments
|
belongs_to :status, inverse_of: :media_attachments
|
||||||
|
|
||||||
has_attached_file :file,
|
has_attached_file :file,
|
||||||
styles: -> (f) { file_styles f },
|
styles: ->(f) { file_styles f },
|
||||||
processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
|
processors: ->(f) { file_processors f },
|
||||||
convert_options: { all: '-quality 90 -strip' }
|
convert_options: { all: '-quality 90 -strip' }
|
||||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
||||||
validates_attachment_size :file, less_than: 8.megabytes
|
validates_attachment_size :file, less_than: 8.megabytes
|
||||||
|
@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
|
||||||
self.file = URI.parse(url)
|
self.file = URI.parse(url)
|
||||||
end
|
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
|
def to_param
|
||||||
shortcode
|
shortcode
|
||||||
end
|
end
|
||||||
|
|
||||||
before_create :set_shortcode
|
before_create :set_shortcode
|
||||||
|
before_post_process :set_type
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
private
|
private
|
||||||
|
|
||||||
def file_styles(f)
|
def file_styles(f)
|
||||||
if f.instance.image?
|
if f.instance.file_content_type == 'image/gif'
|
||||||
{
|
{
|
||||||
original: '1280x1280>',
|
small: IMAGE_STYLES[:small],
|
||||||
small: '400x400>',
|
original: {
|
||||||
}
|
format: 'mp4',
|
||||||
else
|
|
||||||
{
|
|
||||||
small: {
|
|
||||||
convert_options: {
|
convert_options: {
|
||||||
output: {
|
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
|
end
|
||||||
end
|
end
|
||||||
|
@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
|
||||||
break if MediaAttachment.find_by(shortcode: shortcode).nil?
|
break if MediaAttachment.find_by(shortcode: shortcode).nil?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_type
|
||||||
|
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
11
app/models/mute.rb
Normal file
11
app/models/mute.rb
Normal file
|
@ -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
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
class Setting < RailsSettings::Base
|
class Setting < RailsSettings::Base
|
||||||
source Rails.root.join('config/settings.yml')
|
source Rails.root.join('config/settings.yml')
|
||||||
namespace Rails.env
|
|
||||||
|
|
||||||
def to_param
|
def to_param
|
||||||
var
|
var
|
||||||
|
|
|
@ -37,6 +37,9 @@ class Status < ApplicationRecord
|
||||||
scope :remote, -> { where.not(uri: nil) }
|
scope :remote, -> { where.not(uri: nil) }
|
||||||
scope :local, -> { where(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
|
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?
|
def reply?
|
||||||
|
@ -109,8 +112,8 @@ class Status < ApplicationRecord
|
||||||
def as_public_timeline(account = nil, local_only = false)
|
def as_public_timeline(account = nil, local_only = false)
|
||||||
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
|
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
|
||||||
.where(visibility: :public)
|
.where(visibility: :public)
|
||||||
.where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)')
|
.without_replies
|
||||||
.where('statuses.reblog_of_id IS NULL')
|
.without_reblogs
|
||||||
|
|
||||||
query = query.where('accounts.domain IS NULL') if local_only
|
query = query.where('accounts.domain IS NULL') if local_only
|
||||||
|
|
||||||
|
@ -121,7 +124,7 @@ class Status < ApplicationRecord
|
||||||
query = tag.statuses
|
query = tag.statuses
|
||||||
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
|
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
|
||||||
.where(visibility: :public)
|
.where(visibility: :public)
|
||||||
.where('statuses.reblog_of_id IS NULL')
|
.without_reblogs
|
||||||
|
|
||||||
query = query.where('accounts.domain IS NULL') if local_only
|
query = query.where('accounts.domain IS NULL') if local_only
|
||||||
|
|
||||||
|
@ -168,9 +171,9 @@ class Status < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_timeline(query, account)
|
def filter_timeline(query, account)
|
||||||
blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
|
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?
|
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?
|
query = query.where('accounts.silenced = TRUE') if account.silenced? # and if we're hellbanned, only people who are also hellbanned
|
||||||
query
|
query
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -192,6 +195,6 @@ class Status < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_from_context?(status, account)
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
has_and_belongs_to_many :statuses
|
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
|
validates :name, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
|
|
@ -14,9 +14,10 @@ class User < ApplicationRecord
|
||||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
|
validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
|
||||||
validates :email, email: true
|
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 :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 :recent, -> { order('id desc') }
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
|
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||||
|
|
||||||
def send_devise_notification(notification, *args)
|
def send_devise_notification(notification, *args)
|
||||||
devise_mailer.send(notification, self, *args).deliver_later
|
devise_mailer.send(notification, self, *args).deliver_later
|
||||||
|
|
23
app/services/mute_service.rb
Normal file
23
app/services/mute_service.rb
Normal file
|
@ -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
|
|
@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
|
||||||
|
|
||||||
status.save!
|
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"
|
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
|
||||||
DistributionWorker.perform_async(status.id)
|
DistributionWorker.perform_async(status.id)
|
||||||
status
|
status
|
||||||
end
|
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
|
def delete_status
|
||||||
Rails.logger.debug "Deleting remote status #{id}"
|
Rails.logger.debug "Deleting remote status #{id}"
|
||||||
status = Status.find_by(uri: 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)
|
next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
|
||||||
|
|
||||||
mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
|
mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
|
||||||
|
|
||||||
# Notify local user
|
|
||||||
NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
|
|
||||||
|
|
||||||
# So we can skip duplicate mentions
|
# So we can skip duplicate mentions
|
||||||
processed_account_ids << mentioned_account.id
|
processed_account_ids << mentioned_account.id
|
||||||
|
|
|
@ -27,7 +27,7 @@ class ProcessMentionsService < BaseService
|
||||||
mentioned_account.mentions.where(status: status).first_or_create(status: status)
|
mentioned_account.mentions.where(status: status).first_or_create(status: status)
|
||||||
end
|
end
|
||||||
|
|
||||||
status.mentions.each do |mention|
|
status.mentions.includes(:account).each do |mention|
|
||||||
mentioned_account = mention.account
|
mentioned_account = mention.account
|
||||||
|
|
||||||
if mentioned_account.local?
|
if mentioned_account.local?
|
||||||
|
|
11
app/services/unmute_service.rb
Normal file
11
app/services/unmute_service.rb
Normal file
|
@ -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
|
|
@ -4,4 +4,5 @@ attribute :id
|
||||||
node(:following) { |account| @following[account.id] || false }
|
node(:following) { |account| @following[account.id] || false }
|
||||||
node(:followed_by) { |account| @followed_by[account.id] || false }
|
node(:followed_by) { |account| @followed_by[account.id] || false }
|
||||||
node(:blocking) { |account| @blocking[account.id] || false }
|
node(:blocking) { |account| @blocking[account.id] || false }
|
||||||
|
node(:muting) { |account| @muting[account.id] || false }
|
||||||
node(:requested) { |account| @requested[account.id] || false }
|
node(:requested) { |account| @requested[account.id] || false }
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
object @media
|
object @media
|
||||||
attribute :id, :type
|
attribute :id, :type
|
||||||
node(:url) { |media| full_asset_url(media.file.url( :original)) }
|
node(:url) { |media| full_asset_url(media.file.url(:original)) }
|
||||||
node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
|
node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
|
||||||
node(:text_url) { |media| medium_url(media) }
|
node(:text_url) { |media| medium_url(media) }
|
||||||
|
|
2
app/views/api/v1/mutes/index.rabl
Normal file
2
app/views/api/v1/mutes/index.rabl
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
collection @accounts
|
||||||
|
extends 'api/v1/accounts/show'
|
|
@ -1,5 +1,5 @@
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
|
<%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
|
||||||
|
<%= t('application_mailer.settings', link: settings_preferences_url) %>
|
||||||
|
|
|
@ -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}") %>
|
||||||
|
|
15
app/views/notification_mailer/digest.text.erb
Normal file
15
app/views/notification_mailer/digest.text.erb
Normal file
|
@ -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 %>
|
|
@ -1,5 +1,5 @@
|
||||||
<%= display_name(@me) %>,
|
<%= 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 } %>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= display_name(@me) %>,
|
<%= 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}") %>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= display_name(@me) %>,
|
<%= 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") %>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= display_name(@me) %>,
|
<%= 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 } %>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= display_name(@me) %>,
|
<%= 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 } %>
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
= ff.input :reblog, as: :boolean, wrapper: :with_label
|
= ff.input :reblog, as: :boolean, wrapper: :with_label
|
||||||
= ff.input :favourite, as: :boolean, wrapper: :with_label
|
= ff.input :favourite, as: :boolean, wrapper: :with_label
|
||||||
= ff.input :mention, 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|
|
= f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
|
||||||
= ff.input :must_be_follower, as: :boolean, wrapper: :with_label
|
= ff.input :must_be_follower, as: :boolean, wrapper: :with_label
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
.status__content.e-content.p-name.emojify<
|
.status__content.e-content.p-name.emojify<
|
||||||
- unless status.spoiler_text.blank?
|
- unless status.spoiler_text.blank?
|
||||||
%p= status.spoiler_text
|
%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?
|
- unless status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
|
@ -22,9 +22,9 @@
|
||||||
.detailed-status__attachments
|
.detailed-status__attachments
|
||||||
- if status.sensitive?
|
- if status.sensitive?
|
||||||
= render partial: 'stream_entries/content_spoiler'
|
= render partial: 'stream_entries/content_spoiler'
|
||||||
- status.media_attachments.each do |media|
|
.status__attachments__inner
|
||||||
.media-item
|
- status.media_attachments.each do |media|
|
||||||
= 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'}"
|
= render partial: 'stream_entries/media', locals: { media: media }
|
||||||
|
|
||||||
%div.detailed-status__meta
|
%div.detailed-status__meta
|
||||||
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
||||||
|
|
4
app/views/stream_entries/_media.html.haml
Normal file
4
app/views/stream_entries/_media.html.haml
Normal file
|
@ -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 }/
|
|
@ -15,18 +15,19 @@
|
||||||
.status__content.e-content.p-name.emojify<
|
.status__content.e-content.p-name.emojify<
|
||||||
- unless status.spoiler_text.blank?
|
- unless status.spoiler_text.blank?
|
||||||
%p= status.spoiler_text
|
%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?
|
- unless status.media_attachments.empty?
|
||||||
.status__attachments
|
.status__attachments
|
||||||
- if status.sensitive?
|
- if status.sensitive?
|
||||||
= render partial: 'stream_entries/content_spoiler'
|
= render partial: 'stream_entries/content_spoiler'
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
.video-item
|
.status__attachments__inner
|
||||||
= 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
|
||||||
.video-item__play
|
= 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
|
||||||
= fa_icon('play')
|
.video-item__play
|
||||||
|
= fa_icon('play')
|
||||||
- else
|
- else
|
||||||
- status.media_attachments.each do |media|
|
.status__attachments__inner
|
||||||
.media-item
|
- status.media_attachments.each do |media|
|
||||||
= 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'}"
|
= render partial: 'stream_entries/media', locals: { media: media }
|
||||||
|
|
14
app/workers/digest_mailer_worker.rb
Normal file
14
app/workers/digest_mailer_worker.rb
Normal file
|
@ -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
|
|
@ -2,12 +2,14 @@ require_relative 'boot'
|
||||||
|
|
||||||
require 'rails/all'
|
require 'rails/all'
|
||||||
|
|
||||||
require_relative '../app/lib/exceptions'
|
|
||||||
|
|
||||||
# Require the gems listed in Gemfile, including any gems
|
# Require the gems listed in Gemfile, including any gems
|
||||||
# you've limited to :test, :development, or :production.
|
# you've limited to :test, :development, or :production.
|
||||||
Bundler.require(*Rails.groups)
|
Bundler.require(*Rails.groups)
|
||||||
|
|
||||||
|
require_relative '../app/lib/exceptions'
|
||||||
|
require_relative '../lib/paperclip/gif_transcoder'
|
||||||
|
require_relative '../lib/paperclip/video_transcoder'
|
||||||
|
|
||||||
Dotenv::Railtie.load
|
Dotenv::Railtie.load
|
||||||
|
|
||||||
module Mastodon
|
module Mastodon
|
||||||
|
@ -49,12 +51,5 @@ module Mastodon
|
||||||
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
|
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
|
||||||
Doorkeeper::Application.send :include, ApplicationExtension
|
Doorkeeper::Application.send :include, ApplicationExtension
|
||||||
end
|
end
|
||||||
|
|
||||||
config.action_dispatch.default_headers = {
|
|
||||||
'Server' => 'Mastodon',
|
|
||||||
'X-Frame-Options' => 'DENY',
|
|
||||||
'X-Content-Type-Options' => 'nosniff',
|
|
||||||
'X-XSS-Protection' => '1; mode=block',
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -109,4 +109,11 @@ Rails.application.configure do
|
||||||
config.to_prepare do
|
config.to_prepare do
|
||||||
StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
|
StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
config.action_dispatch.default_headers = {
|
||||||
|
'Server' => 'Mastodon',
|
||||||
|
'X-Frame-Options' => 'DENY',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-XSS-Protection' => '1; mode=block',
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
Paperclip.options[:read_timeout] = 60
|
Paperclip.options[:read_timeout] = 60
|
||||||
|
|
||||||
|
Paperclip.interpolates :filename do |attachment, style|
|
||||||
|
return attachment.original_filename if style == :original
|
||||||
|
[basename(attachment, style), extension(attachment, style)].delete_if(&:empty?).join('.')
|
||||||
|
end
|
||||||
|
|
||||||
if ENV['S3_ENABLED'] == 'true'
|
if ENV['S3_ENABLED'] == 'true'
|
||||||
Aws.eager_autoload!(services: %w(S3))
|
Aws.eager_autoload!(services: %w(S3))
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
Rabl.configure do |config|
|
Rabl.configure do |config|
|
||||||
config.cache_all_output = false
|
config.cache_all_output = false
|
||||||
config.cache_sources = !!Rails.env.production?
|
config.cache_sources = Rails.env.production?
|
||||||
config.include_json_root = false
|
config.include_json_root = false
|
||||||
config.view_paths = [Rails.root.join('app/views')]
|
config.view_paths = [Rails.root.join('app/views')]
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
class Rack::Attack
|
class Rack::Attack
|
||||||
# Rate limits for the API
|
# Rate limits for the API
|
||||||
throttle('api', limit: 150, period: 5.minutes) do |req|
|
throttle('api', limit: 300, period: 5.minutes) do |req|
|
||||||
req.ip if req.path.match(/\A\/api\/v/)
|
req.ip if req.path.match(/\A\/api\/v/)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ class Rack::Attack
|
||||||
headers = {
|
headers = {
|
||||||
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
||||||
'X-RateLimit-Remaining' => '0',
|
'X-RateLimit-Remaining' => '0',
|
||||||
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
|
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6),
|
||||||
}
|
}
|
||||||
|
|
||||||
[429, headers, [{ error: 'Throttled' }.to_json]]
|
[429, headers, [{ error: 'Throttled' }.to_json]]
|
||||||
|
|
|
@ -29,6 +29,8 @@ en:
|
||||||
unfollow: Unfollow
|
unfollow: Unfollow
|
||||||
application_mailer:
|
application_mailer:
|
||||||
signature: Mastodon notifications from %{instance}
|
signature: Mastodon notifications from %{instance}
|
||||||
|
settings: 'Change e-mail preferences: %{link}'
|
||||||
|
view: 'View:'
|
||||||
applications:
|
applications:
|
||||||
invalid_url: The provided URL is invalid
|
invalid_url: The provided URL is invalid
|
||||||
auth:
|
auth:
|
||||||
|
@ -83,6 +85,15 @@ en:
|
||||||
reblog:
|
reblog:
|
||||||
body: 'Your status was boosted by %{name}:'
|
body: 'Your status was boosted by %{name}:'
|
||||||
subject: "%{name} boosted your status"
|
subject: "%{name} boosted your status"
|
||||||
|
digest:
|
||||||
|
subject:
|
||||||
|
one: "1 new notification since your last visit 🐘"
|
||||||
|
other: "%{count} new notifications since your last visit 🐘"
|
||||||
|
body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
|
||||||
|
mention: "%{name} mentioned you in:"
|
||||||
|
new_followers_summary:
|
||||||
|
one: You have acquired one new follower! Yay!
|
||||||
|
other: You have gotten %{count} new followers! Amazing!
|
||||||
pagination:
|
pagination:
|
||||||
next: Next
|
next: Next
|
||||||
prev: Prev
|
prev: Prev
|
||||||
|
|
|
@ -34,6 +34,7 @@ en:
|
||||||
follow_request: Send e-mail when someone requests to follow you
|
follow_request: Send e-mail when someone requests to follow you
|
||||||
mention: Send e-mail when someone mentions you
|
mention: Send e-mail when someone mentions you
|
||||||
reblog: Send e-mail when someone reblogs your status
|
reblog: Send e-mail when someone reblogs your status
|
||||||
|
digest: Send digest e-mails
|
||||||
'no': 'No'
|
'no': 'No'
|
||||||
required:
|
required:
|
||||||
mark: "*"
|
mark: "*"
|
||||||
|
|
|
@ -127,6 +127,7 @@ Rails.application.routes.draw do
|
||||||
resources :media, only: [:create]
|
resources :media, only: [:create]
|
||||||
resources :apps, only: [:create]
|
resources :apps, only: [:create]
|
||||||
resources :blocks, only: [:index]
|
resources :blocks, only: [:index]
|
||||||
|
resources :mutes, only: [:index]
|
||||||
resources :favourites, only: [:index]
|
resources :favourites, only: [:index]
|
||||||
resources :reports, only: [:index, :create]
|
resources :reports, only: [:index, :create]
|
||||||
resources :site, only: [:index]
|
resources :site, only: [:index]
|
||||||
|
@ -153,7 +154,6 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
member do
|
member do
|
||||||
get :statuses
|
get :statuses
|
||||||
get 'statuses/media', to: 'accounts#media_statuses', as: :media_statuses
|
|
||||||
get :followers
|
get :followers
|
||||||
get :following
|
get :following
|
||||||
|
|
||||||
|
@ -161,6 +161,8 @@ Rails.application.routes.draw do
|
||||||
post :unfollow
|
post :unfollow
|
||||||
post :block
|
post :block
|
||||||
post :unblock
|
post :unblock
|
||||||
|
post :mute
|
||||||
|
post :unmute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -178,5 +180,8 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
root 'home#index'
|
root 'home#index'
|
||||||
|
|
||||||
|
get '/:username', to: redirect('/users/%{username}')
|
||||||
|
get '/:username/:id', to: redirect('/users/%{username}/updates/%{id}')
|
||||||
|
|
||||||
match '*unmatched_route', via: :all, to: 'application#raise_not_found'
|
match '*unmatched_route', via: :all, to: 'application#raise_not_found'
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ defaults: &defaults
|
||||||
favourite: false
|
favourite: false
|
||||||
mention: false
|
mention: false
|
||||||
follow_request: true
|
follow_request: true
|
||||||
|
digest: true
|
||||||
interactions:
|
interactions:
|
||||||
must_be_follower: false
|
must_be_follower: false
|
||||||
must_be_following: false
|
must_be_following: false
|
||||||
|
|
12
db/migrate/20170301222600_create_mutes.rb
Normal file
12
db/migrate/20170301222600_create_mutes.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class CreateMutes < ActiveRecord::Migration[5.0]
|
||||||
|
def change
|
||||||
|
create_table :mutes do |t|
|
||||||
|
t.integer :account_id, null: false
|
||||||
|
t.integer :target_account_id, null: false
|
||||||
|
t.timestamps null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :mutes, [:account_id, :target_account_id], unique: true
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddLastEmailedAtToUsers < ActiveRecord::Migration[5.0]
|
||||||
|
def change
|
||||||
|
add_column :users, :last_emailed_at, :datetime, null: true, default: nil
|
||||||
|
end
|
||||||
|
end
|
12
db/migrate/20170304202101_add_type_to_media_attachments.rb
Normal file
12
db/migrate/20170304202101_add_type_to_media_attachments.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
|
||||||
|
def up
|
||||||
|
add_column :media_attachments, :type, :integer, default: 0, null: false
|
||||||
|
|
||||||
|
MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
|
||||||
|
MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :media_attachments, :type
|
||||||
|
end
|
||||||
|
end
|
12
db/schema.rb
12
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 20170217012631) do
|
ActiveRecord::Schema.define(version: 20170304202101) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "shortcode"
|
t.string "shortcode"
|
||||||
|
t.integer "type", default: 0, null: false
|
||||||
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
|
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
|
||||||
t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
|
t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
|
||||||
end
|
end
|
||||||
|
@ -110,6 +111,14 @@ ActiveRecord::Schema.define(version: 20170217012631) do
|
||||||
t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
|
t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "mutes", force: :cascade do |t|
|
||||||
|
t.integer "account_id", null: false
|
||||||
|
t.integer "target_account_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true, using: :btree
|
||||||
|
end
|
||||||
|
|
||||||
create_table "notifications", force: :cascade do |t|
|
create_table "notifications", force: :cascade do |t|
|
||||||
t.integer "account_id"
|
t.integer "account_id"
|
||||||
t.integer "activity_id"
|
t.integer "activity_id"
|
||||||
|
@ -275,6 +284,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
|
||||||
t.string "encrypted_otp_secret_salt"
|
t.string "encrypted_otp_secret_salt"
|
||||||
t.integer "consumed_timestep"
|
t.integer "consumed_timestep"
|
||||||
t.boolean "otp_required_for_login"
|
t.boolean "otp_required_for_login"
|
||||||
|
t.datetime "last_emailed_at"
|
||||||
t.index ["account_id"], name: "index_users_on_account_id", using: :btree
|
t.index ["account_id"], name: "index_users_on_account_id", using: :btree
|
||||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
|
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
|
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
|
||||||
|
|
|
@ -6,23 +6,16 @@ These people make the development of Mastodon possible through [Patreon](https:/
|
||||||
**Extra special Patrons**
|
**Extra special Patrons**
|
||||||
|
|
||||||
- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
|
- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
|
||||||
- [glocal](https://mastodon.social/users/glocal)
|
|
||||||
- [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
|
- [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
|
||||||
- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
|
- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
|
||||||
- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
|
- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
|
||||||
- [Zeiphner](https://mastodon.social/users/Zeipher)
|
- [Zeipher](https://mastodon.social/users/Zeipher)
|
||||||
- [Effy Elden](https://toot.zone/users/effy)
|
- [Effy Elden](https://toot.zone/users/effy)
|
||||||
- [Zoë Quinn](https://mastodon.social/users/zoequinn)
|
- [Zoë Quinn](https://mastodon.social/users/zoequinn)
|
||||||
|
|
||||||
**Thank you to the following people**
|
**Thank you to the following people**
|
||||||
|
|
||||||
- [Sophia Park](https://mastodon.social/users/sophia)
|
|
||||||
- [WelshPixie](https://mastodon.social/users/WelshPixie)
|
|
||||||
- [John Parker](https://mastodon.social/users/Middaparka)
|
|
||||||
- [Christina Hendricks](https://mastodon.social/users/clhendricksbc)
|
|
||||||
- [Jelle](http://jelv.nl)
|
|
||||||
- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
|
- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
|
||||||
- [Martin Tithonium](https://mastodon.social/users/tithonium)
|
|
||||||
- [Edward Saperia](https://nwspk.com)
|
- [Edward Saperia](https://nwspk.com)
|
||||||
- [Yoz Grahame](http://yoz.com/)
|
- [Yoz Grahame](http://yoz.com/)
|
||||||
- [Jenn Kaplan](https://gay.crime.team/users/jkap)
|
- [Jenn Kaplan](https://gay.crime.team/users/jkap)
|
||||||
|
@ -33,5 +26,21 @@ These people make the development of Mastodon possible through [Patreon](https:/
|
||||||
- [Niels Roesen Abildgaard](http://hypesystem.dk/)
|
- [Niels Roesen Abildgaard](http://hypesystem.dk/)
|
||||||
- [Zatnosk](https://github.com/Zatnosk)
|
- [Zatnosk](https://github.com/Zatnosk)
|
||||||
- [Spex Bluefox](https://mastodon.social/users/Spex)
|
- [Spex Bluefox](https://mastodon.social/users/Spex)
|
||||||
- [Sam Waldie](https://mastodon.social/users/denjin)
|
|
||||||
- [J. C. Holder](http://jcholder.com/)
|
- [J. C. Holder](http://jcholder.com/)
|
||||||
|
- [glocal](https://mastodon.social/users/glocal)
|
||||||
|
- [jk](https://mastodon.social/users/jk)
|
||||||
|
- [C418](https://mastodon.social/users/C418)
|
||||||
|
- [halcy](https://icosahedron.website/users/halcy)
|
||||||
|
- [Extropic](https://gnusocial.no/extropic)
|
||||||
|
- [Pat Monaghan](http://iwrite.software/)
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
- TBD
|
||||||
|
|
|
@ -5,11 +5,13 @@ Some people have started working on apps for the Mastodon API. Here is a list of
|
||||||
|
|
||||||
|App|Platform|Link|Developer(s)|
|
|App|Platform|Link|Developer(s)|
|
||||||
|---|--------|----|------------|
|
|---|--------|----|------------|
|
||||||
|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
|
|
||||||
|Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
|
|Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
|
||||||
|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
|
|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>|[@charlag@mastodon.social](https://mastodon.social/users/charlag)|
|
||||||
|tootstream|command-line|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
|
|
||||||
|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>||
|
|
||||||
|Tooter|Chrome extension|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
|
|
||||||
|TootyFruity|Android|<https://play.google.com/store/apps/details?id=ch.kevinegli.tootyfruity221258>|[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)|
|
|TootyFruity|Android|<https://play.google.com/store/apps/details?id=ch.kevinegli.tootyfruity221258>|[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)|
|
||||||
|
|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
|
||||||
|
|Amarok|iOS|<https://itunes.apple.com/us/app/amarok-for-mastodon/id1214116200?ls=1&mt=8>|[@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy)|
|
||||||
|
|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
|
||||||
|
|Tooter|Chrome|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
|
||||||
|
|tootstream|CLI|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
|
||||||
|
|
||||||
If you have a project like this, let me know so I can add it to the list!
|
If you have a project like this, let me know so I can add it to the list!
|
||||||
|
|
|
@ -11,8 +11,9 @@ List of Known Mastodon instances
|
||||||
| [epiktistes.com](https://epiktistes.com) |N/A|Yes|
|
| [epiktistes.com](https://epiktistes.com) |N/A|Yes|
|
||||||
| [on.vu](https://on.vu) | Appears defunct|No|
|
| [on.vu](https://on.vu) | Appears defunct|No|
|
||||||
| [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
|
| [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
|
||||||
| [gnusocial.me](https://gnusocial.me) |Yes, it's a mastodon instance now|Yes|
|
|
||||||
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
|
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
|
||||||
| [memetastic.space](https://memetastic.space) |Memes|Yes|
|
| [memetastic.space](https://memetastic.space) |Memes|Yes|
|
||||||
|
| [social.diskseven.com](https://social.diskseven.com) |Single user|No|
|
||||||
|
| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|
|
||||||
|
|
||||||
Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
|
Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
|
||||||
|
|
|
@ -76,6 +76,10 @@ Query parameters:
|
||||||
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
|
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
|
||||||
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
|
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
|
||||||
|
|
||||||
|
Query parameters for public and tag timelines only:
|
||||||
|
|
||||||
|
- `local` (optional): Only return statuses originating from this instance
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
**GET /api/v1/notifications**
|
**GET /api/v1/notifications**
|
||||||
|
@ -116,7 +120,14 @@ Returns authenticated user's account.
|
||||||
|
|
||||||
**GET /api/v1/accounts/:id/statuses**
|
**GET /api/v1/accounts/:id/statuses**
|
||||||
|
|
||||||
Returns statuses by user. Same options as timeline are permitted.
|
Returns statuses by user.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
|
||||||
|
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
|
||||||
|
- `only_media` (optional): Only return statuses that have media attachments
|
||||||
|
- `exclude_replies` (optional): Skip statuses that reply to other statuses
|
||||||
|
|
||||||
**GET /api/v1/accounts/:id/following**
|
**GET /api/v1/accounts/:id/following**
|
||||||
|
|
||||||
|
@ -128,7 +139,7 @@ Returns users the given user is followed by.
|
||||||
|
|
||||||
**GET /api/v1/accounts/relationships**
|
**GET /api/v1/accounts/relationships**
|
||||||
|
|
||||||
Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts.
|
Returns relationships (`following`, `followed_by`, `blocking`, `muting`, `requested`) of the current user to a list of given accounts.
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
|
|
||||||
|
@ -147,6 +158,14 @@ Query parameters:
|
||||||
|
|
||||||
Returns accounts blocked by authenticated user.
|
Returns accounts blocked by authenticated user.
|
||||||
|
|
||||||
|
**GET /api/v1/mutes**
|
||||||
|
|
||||||
|
Returns accounts muted by authenticated user.
|
||||||
|
|
||||||
|
**GET /api/v1/follow_requests**
|
||||||
|
|
||||||
|
Returns accounts that want to follow the authenticated user but are waiting for approval.
|
||||||
|
|
||||||
**GET /api/v1/favourites**
|
**GET /api/v1/favourites**
|
||||||
|
|
||||||
Returns statuses favourited by authenticated user.
|
Returns statuses favourited by authenticated user.
|
||||||
|
@ -215,6 +234,13 @@ Returns the updated relationship to the user.
|
||||||
Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site.
|
Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site.
|
||||||
Does not require authentication.
|
Does not require authentication.
|
||||||
|
|
||||||
|
# Muting and unmuting users
|
||||||
|
|
||||||
|
**POST /api/v1/accounts/:id/mute**
|
||||||
|
**POST /api/v1/accounts/:id/unmute**
|
||||||
|
|
||||||
|
Returns the updated relationship to the user.
|
||||||
|
|
||||||
### OAuth apps
|
### OAuth apps
|
||||||
|
|
||||||
**POST /api/v1/apps**
|
**POST /api/v1/apps**
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
Push notifications
|
Push notifications
|
||||||
==================
|
==================
|
||||||
|
|
||||||
**Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration**
|
See <https://github.com/Gargron/tusky-api> for an example of how to create push notifications for a mobile app. It involves using the Mastodon streaming API on behalf of the app's users, as a sort of proxy.
|
||||||
|
|
21
lib/paperclip/gif_transcoder.rb
Normal file
21
lib/paperclip/gif_transcoder.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
# This transcoder is only to be used for the MediaAttachment model
|
||||||
|
# to convert animated gifs to webm
|
||||||
|
class GifTranscoder < Paperclip::Processor
|
||||||
|
def make
|
||||||
|
num_frames = identify('-format %n :file', file: file.path).to_i
|
||||||
|
|
||||||
|
return file unless options[:style] == :original && num_frames > 1
|
||||||
|
|
||||||
|
final_file = Paperclip::Transcoder.make(file, options, attachment)
|
||||||
|
|
||||||
|
attachment.instance.file_file_name = 'media.mp4'
|
||||||
|
attachment.instance.file_content_type = 'video/mp4'
|
||||||
|
attachment.instance.type = MediaAttachment.types[:gifv]
|
||||||
|
|
||||||
|
final_file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
14
lib/paperclip/video_transcoder.rb
Normal file
14
lib/paperclip/video_transcoder.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
# This transcoder is only to be used for the MediaAttachment model
|
||||||
|
# to check when uploaded videos are actually gifv's
|
||||||
|
class VideoTranscoder < Paperclip::Processor
|
||||||
|
def make
|
||||||
|
meta = ::Av.cli.identify(@file.path)
|
||||||
|
attachment.instance.type = MediaAttachment.types[:gifv] unless meta[:audio_encode]
|
||||||
|
|
||||||
|
Paperclip::Transcoder.make(file, options, attachment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -43,7 +43,7 @@ namespace :mastodon do
|
||||||
namespace :feeds do
|
namespace :feeds do
|
||||||
desc 'Clear timelines of inactive users'
|
desc 'Clear timelines of inactive users'
|
||||||
task clear: :environment do
|
task clear: :environment do
|
||||||
User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
|
User.confirmed.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
|
||||||
Redis.current.del(FeedManager.instance.key(:home, user.account_id))
|
Redis.current.del(FeedManager.instance.key(:home, user.account_id))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -53,4 +53,13 @@ namespace :mastodon do
|
||||||
Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
|
Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :emails do
|
||||||
|
desc 'Send out digest e-mails'
|
||||||
|
task digest: :environment do
|
||||||
|
User.confirmed.joins(:account).where(accounts: { silenced: false, suspended: false }).where('current_sign_in_at < ?', 20.days.ago).find_each do |user|
|
||||||
|
DigestMailerWorker.perform_async(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"css-loader": "^0.26.2",
|
"css-loader": "^0.26.2",
|
||||||
"dotenv": "^4.0.0",
|
"dotenv": "^4.0.0",
|
||||||
"emojione": "latest",
|
"emojione": "latest",
|
||||||
|
"emojione-picker": "^2.0.1",
|
||||||
"enzyme": "^2.7.1",
|
"enzyme": "^2.7.1",
|
||||||
"es6-promise": "^3.2.1",
|
"es6-promise": "^3.2.1",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
|
@ -40,6 +41,7 @@
|
||||||
"react": "^15.4.2",
|
"react": "^15.4.2",
|
||||||
"react-addons-perf": "^15.4.2",
|
"react-addons-perf": "^15.4.2",
|
||||||
"react-addons-pure-render-mixin": "^15.4.2",
|
"react-addons-pure-render-mixin": "^15.4.2",
|
||||||
|
"react-addons-shallow-compare": "^15.4.2",
|
||||||
"react-addons-test-utils": "^15.4.2",
|
"react-addons-test-utils": "^15.4.2",
|
||||||
"react-autosuggest": "^7.0.1",
|
"react-autosuggest": "^7.0.1",
|
||||||
"react-decoration": "^1.4.0",
|
"react-decoration": "^1.4.0",
|
||||||
|
@ -60,7 +62,6 @@
|
||||||
"redis": "^2.6.5",
|
"redis": "^2.6.5",
|
||||||
"redux": "^3.6.0",
|
"redux": "^3.6.0",
|
||||||
"redux-immutable": "^3.1.0",
|
"redux-immutable": "^3.1.0",
|
||||||
"redux-sounds": "^1.1.1",
|
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.2.0",
|
||||||
"reselect": "^2.5.4",
|
"reselect": "^2.5.4",
|
||||||
"sass-loader": "^6.0.2",
|
"sass-loader": "^6.0.2",
|
||||||
|
|
|
@ -116,6 +116,44 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST #mute' do
|
||||||
|
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
|
||||||
|
|
||||||
|
before do
|
||||||
|
user.account.follow!(other_account)
|
||||||
|
post :mute, params: {id: other_account.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not remove the following relation between user and target user' do
|
||||||
|
expect(user.account.following?(other_account)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a muting relation' do
|
||||||
|
expect(user.account.muting?(other_account)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #unmute' do
|
||||||
|
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
|
||||||
|
|
||||||
|
before do
|
||||||
|
user.account.mute!(other_account)
|
||||||
|
post :unmute, params: { id: other_account.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes the muting relation between user and target user' do
|
||||||
|
expect(user.account.muting?(other_account)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'GET #relationships' do
|
describe 'GET #relationships' do
|
||||||
let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account }
|
let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account }
|
||||||
let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account }
|
let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account }
|
||||||
|
|
19
spec/controllers/api/v1/mutes_controller_spec.rb
Normal file
19
spec/controllers/api/v1/mutes_controller_spec.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Api::V1::MutesController, type: :controller do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||||
|
let(:token) { double acceptable?: true, resource_owner_id: user.id }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #index' do
|
||||||
|
it 'returns http success' do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
spec/fabricators/mute_fabricator.rb
Normal file
3
spec/fabricators/mute_fabricator.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Fabricator(:mute) do
|
||||||
|
|
||||||
|
end
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue