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