Merge branch 'master' into mastodon-site-api

main
Eugen 2017-03-15 22:55:22 +01:00 committed by GitHub
commit e245115f47
109 changed files with 1572 additions and 358 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
if (getState().getIn(['accounts', id], null) !== null) {
return;
}
dispatch(fetchAccountRequest(id)); dispatch(fetchAccountRequest(id));
api(getState).get(`/api/v1/accounts/${id}`).then(response => { api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(fetchAccountSuccess(response.data)); dispatch(fetchAccountSuccess(response.data));
dispatch(fetchRelationships([id]));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFail(id, error)); dispatch(fetchAccountFail(id, error));
}); });
@ -328,6 +341,76 @@ export function unblockAccountFail(error) {
}; };
}; };
export function muteAccount(id) {
return (dispatch, getState) => {
dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => {
dispatch(muteAccountFail(id, error));
});
};
};
export function unmuteAccount(id) {
return (dispatch, getState) => {
dispatch(unmuteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
dispatch(unmuteAccountSuccess(response.data));
}).catch(error => {
dispatch(unmuteAccountFail(id, error));
});
};
};
export function muteAccountRequest(id) {
return {
type: ACCOUNT_MUTE_REQUEST,
id
};
};
export function muteAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_MUTE_SUCCESS,
relationship,
statuses
};
};
export function muteAccountFail(error) {
return {
type: ACCOUNT_MUTE_FAIL,
error
};
};
export function unmuteAccountRequest(id) {
return {
type: ACCOUNT_UNMUTE_REQUEST,
id
};
};
export function unmuteAccountSuccess(relationship) {
return {
type: ACCOUNT_UNMUTE_SUCCESS,
relationship
};
};
export function unmuteAccountFail(error) {
return {
type: ACCOUNT_UNMUTE_FAIL,
error
};
};
export function fetchFollowers(id) { export function fetchFollowers(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchFollowersRequest(id)); dispatch(fetchFollowersRequest(id));

View File

@ -28,6 +28,8 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -85,8 +87,13 @@ export function submitCompose() {
dispatch(updateTimeline('home', { ...response.data })); dispatch(updateTimeline('home', { ...response.data }));
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
dispatch(updateTimeline('community', { ...response.data })); if (getState().getIn(['timelines', 'community', 'loaded'])) {
dispatch(updateTimeline('public', { ...response.data })); dispatch(updateTimeline('community', { ...response.data }));
}
if (getState().getIn(['timelines', 'public', 'loaded'])) {
dispatch(updateTimeline('public', { ...response.data }));
}
} }
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
@ -255,3 +262,11 @@ export function changeComposeListability(checked) {
checked checked
}; };
}; };
export function insertEmojiCompose(position, emoji) {
return {
type: COMPOSE_EMOJI_INSERT,
position,
emoji
};
};

View File

@ -106,18 +106,20 @@ export function expandTimeline(timeline) {
return; return;
} }
const next = getState().getIn(['timelines', timeline, 'next']); if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
const params = getState().getIn(['timelines', timeline, 'params'], {});
if (next === null) {
return; return;
} }
const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
const params = getState().getIn(['timelines', timeline, 'params'], {});
const lastId = getState().getIn(['timelines', timeline, 'items']).last();
dispatch(expandTimelineRequest(timeline)); dispatch(expandTimelineRequest(timeline));
api(getState).get(next, { api(getState).get(path, {
params: { params: {
...params, ...params,
max_id: lastId,
limit: 10 limit: 10
} }
}).then(response => { }).then(response => {

View File

@ -1,5 +1,6 @@
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { isRtl } from '../rtl';
const textAtCursorMatchesToken = (str, caretPosition) => { const textAtCursorMatchesToken = (str, caretPosition) => {
let word; let word;
@ -39,7 +40,8 @@ const AutosuggestTextarea = React.createClass({
onSuggestionsFetchRequested: React.PropTypes.func.isRequired, onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
onChange: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired,
onKeyUp: React.PropTypes.func, onKeyUp: React.PropTypes.func,
onKeyDown: React.PropTypes.func onKeyDown: React.PropTypes.func,
onPaste: React.PropTypes.func.isRequired,
}, },
getInitialState () { getInitialState () {
@ -172,10 +174,22 @@ const AutosuggestTextarea = React.createClass({
}) })
}, },
onPaste (e) {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files)
e.preventDefault();
}
},
render () { render () {
const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props; const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state; const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea'; const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return ( return (
<div className='autosuggest-textarea'> <div className='autosuggest-textarea'>
@ -192,6 +206,8 @@ const AutosuggestTextarea = React.createClass({
onBlur={this.onBlur} onBlur={this.onBlur}
onDragEnter={this.onDragEnter} onDragEnter={this.onDragEnter}
onDragExit={this.onDragExit} onDragExit={this.onDragExit}
onPaste={this.onPaste}
style={style}
/> />
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'> <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>

View File

@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
handleClick () { handleClick () {
this.context.router.goBack(); if (window.history && window.history.length == 1) this.context.router.push("/");
else this.context.router.goBack();
}, },
render () { render () {

View File

@ -10,12 +10,44 @@ const DropdownMenu = React.createClass({
direction: React.PropTypes.string direction: React.PropTypes.string
}, },
getDefaultProps () {
return {
direction: 'left'
};
},
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
setRef (c) { setRef (c) {
this.dropdown = c; this.dropdown = c;
}, },
handleClick (i, e) {
const { action } = this.props.items[i];
if (typeof action === 'function') {
e.preventDefault();
action();
this.dropdown.hide();
}
},
renderItem (item, i) {
if (item === null) {
return <li key={i} className='dropdown__sep' />;
}
const { text, action, href = '#' } = item;
return (
<li key={i}>
<a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}>
{text}
</a>
</li>
);
},
render () { render () {
const { icon, items, size, direction } = this.props; const { icon, items, size, direction } = this.props;
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right"; const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({
<DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}> <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
<ul> <ul>
{items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => { {items.map(this.renderItem)}
if (typeof action === 'function') {
e.preventDefault();
action();
this.dropdown.hide();
}
}}>{text}</a></li>)}
</ul> </ul>
</DropdownContent> </DropdownContent>
</Dropdown> </Dropdown>

View File

@ -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;

View File

@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' } toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
@ -43,6 +44,141 @@ const spoilerButtonStyle = {
zIndex: '100' zIndex: '100'
}; };
const itemStyle = {
boxSizing: 'border-box',
position: 'relative',
float: 'left',
border: 'none',
display: 'block'
};
const thumbStyle = {
display: 'block',
width: '100%',
height: '100%',
textDecoration: 'none',
backgroundSize: 'cover',
cursor: 'zoom-in'
};
const gifvThumbStyle = {
position: 'relative',
zIndex: '1',
width: '100%',
height: '100%',
objectFit: 'cover',
top: '50%',
transform: 'translateY(-50%)',
cursor: 'zoom-in'
};
const Item = React.createClass({
propTypes: {
attachment: ImmutablePropTypes.map.isRequired,
index: React.PropTypes.number.isRequired,
size: React.PropTypes.number.isRequired,
onClick: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick (e) {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
},
render () {
const { attachment, index, size } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
thumbnail = (
<a
href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
onClick={this.handleClick}
target='_blank'
style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
/>
);
} else if (attachment.get('type') === 'gifv') {
thumbnail = (
<video
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={!isIOS()}
loop={true}
muted={true}
style={gifvThumbStyle}
/>
);
}
return (
<div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
});
const MediaGallery = React.createClass({ const MediaGallery = React.createClass({
getInitialState () { getInitialState () {
@ -61,17 +197,12 @@ const MediaGallery = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
handleClick (index, e) { handleOpen (e) {
if (e.button === 0) { this.setState({ visible: !this.state.visible });
e.preventDefault();
this.props.onOpenMedia(this.props.media, index);
}
e.stopPropagation();
}, },
handleOpen () { handleClick (index) {
this.setState({ visible: !this.state.visible }); this.props.onOpenMedia(this.props.media, index);
}, },
render () { render () {
@ -80,87 +211,31 @@ const MediaGallery = React.createClass({
let children; let children;
if (!this.state.visible) { if (!this.state.visible) {
let warning;
if (sensitive) { if (sensitive) {
children = ( warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else { } else {
children = ( warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} }
children = (
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}>{warning}</span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else { } else {
const size = media.take(4).size; const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
children = media.take(4).map((attachment, i) => {
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
if (size === 2) {
if (i === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (i === 0) {
right = '2px';
} else if (i > 0) {
left = '2px';
}
if (i === 1) {
bottom = '2px';
} else if (i > 1) {
top = '2px';
}
} else if (size === 4) {
if (i === 0 || i === 2) {
right = '2px';
}
if (i === 1 || i === 3) {
left = '2px';
}
if (i < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
return (
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
<a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
</div>
);
});
} }
return ( return (
<div style={{ ...outerStyle, height: `${this.props.height}px` }}> <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
<div style={spoilerButtonStyle} > <div style={spoilerButtonStyle}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
</div> </div>
{children} {children}
</div> </div>
); );

View File

@ -6,13 +6,13 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
block: { id: 'account.block', defaultMessage: 'Block' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand' }, open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report' } report: { id: 'status.report', defaultMessage: 'Report @{name}' }
}); });
const StatusActionBar = React.createClass({ const StatusActionBar = React.createClass({
@ -74,13 +74,15 @@ const StatusActionBar = React.createClass({
let menu = []; let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
menu.push(null);
if (status.getIn(['account', 'id']) === me) { if (status.getIn(['account', 'id']) === me) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick }); menu.push(null);
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }
return ( return (

View File

@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import emojify from '../emoji'; import emojify from '../emoji';
import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink'; import Permalink from './permalink';
@ -92,6 +93,11 @@ const StatusContent = React.createClass({
const content = { __html: emojify(status.get('content')) }; const content = { __html: emojify(status.get('content')) };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
const directionStyle = { direction: 'ltr' };
if (isRtl(status.get('content'))) {
directionStyle.direction = 'rtl';
}
if (status.get('spoiler_text').length > 0) { if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = ''; let mentionsPlaceholder = '';
@ -116,14 +122,14 @@ const StatusContent = React.createClass({
{mentionsPlaceholder} {mentionsPlaceholder}
<div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
</div> </div>
); );
} else { } else {
return ( return (
<div <div
className='status__content' className='status__content'
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer', ...directionStyle }}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}

View File

@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({ const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
@ -61,12 +62,14 @@ const VideoPlayer = React.createClass({
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
width: React.PropTypes.number, width: React.PropTypes.number,
height: React.PropTypes.number, height: React.PropTypes.number,
sensitive: React.PropTypes.bool sensitive: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired,
autoplay: React.PropTypes.bool
}, },
getDefaultProps () { getDefaultProps () {
return { return {
width: 196, width: 239,
height: 110 height: 110
}; };
}, },
@ -75,7 +78,8 @@ const VideoPlayer = React.createClass({
return { return {
visible: !this.props.sensitive, visible: !this.props.sensitive,
preview: true, preview: true,
muted: true muted: true,
hasAudio: true
}; };
}, },
@ -108,8 +112,42 @@ const VideoPlayer = React.createClass({
}); });
}, },
setRef (c) {
this.video = c;
},
handleLoadedData () {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
},
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
},
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
},
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
},
render () { render () {
const { media, intl, width, height, sensitive } = this.props; const { media, intl, width, height, sensitive, autoplay } = this.props;
let spoilerButton = ( let spoilerButton = (
<div style={spoilerButtonStyle} > <div style={spoilerButtonStyle} >
@ -117,6 +155,16 @@ const VideoPlayer = React.createClass({
</div> </div>
); );
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div style={muteStyle}>
<IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) { if (!this.state.visible) {
if (sensitive) { if (sensitive) {
return ( return (
@ -128,7 +176,7 @@ const VideoPlayer = React.createClass({
); );
} else { } else {
return ( return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}> <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -137,7 +185,7 @@ const VideoPlayer = React.createClass({
} }
} }
if (this.state.preview) { if (this.state.preview && !autoplay) {
return ( return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
{spoilerButton} {spoilerButton}
@ -149,8 +197,8 @@ const VideoPlayer = React.createClass({
return ( return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
{spoilerButton} {spoilerButton}
<div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div> {muteButton}
<video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div> </div>
); );
} }

View File

@ -5,7 +5,9 @@ import {
followAccount, followAccount,
unfollowAccount, unfollowAccount,
blockAccount, blockAccount,
unblockAccount unblockAccount,
muteAccount,
unmuteAccount,
} from '../actions/accounts'; } from '../actions/accounts';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
} else { } else {
dispatch(blockAccount(account.get('id'))); dispatch(blockAccount(account.get('id')));
} }
},
onMute (account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(muteAccount(account.get('id')));
}
} }
}); });

View File

@ -11,7 +11,10 @@ import {
unreblog, unreblog,
unfavourite unfavourite
} from '../actions/interactions'; } from '../actions/interactions';
import { blockAccount } from '../actions/accounts'; import {
blockAccount,
muteAccount
} from '../actions/accounts';
import { deleteStatus } from '../actions/statuses'; import { deleteStatus } from '../actions/statuses';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openMedia } from '../actions/modal'; import { openMedia } from '../actions/modal';
@ -69,7 +72,11 @@ const mapDispatchToProps = (dispatch) => ({
onReport (status) { onReport (status) {
dispatch(initReport(status.get('account'), status)); dispatch(initReport(status.get('account'), status));
} },
onMute (account) {
dispatch(muteAccount(account.get('id')));
},
}); });

View File

@ -5,14 +5,16 @@ import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
mention: { id: 'account.mention', defaultMessage: 'Mention' }, mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
block: { id: 'account.block', defaultMessage: 'Block' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
block: { id: 'account.block', defaultMessage: 'Block' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' },
report: { id: 'account.report', defaultMessage: 'Report' } disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
}); });
const outerDropdownStyle = { const outerDropdownStyle = {
@ -35,6 +37,7 @@ const ActionBar = React.createClass({
onBlock: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func.isRequired, onReport: React.PropTypes.func.isRequired,
onMute: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired intl: React.PropTypes.object.isRequired
}, },
@ -44,21 +47,31 @@ const ActionBar = React.createClass({
const { account, me, intl } = this.props; const { account, me, intl } = this.props;
let menu = []; let menu = [];
let extraInfo = '';
menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention }); menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push(null);
if (account.get('id') === me) { if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
} else if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
} else if (account.getIn(['relationship', 'following'])) {
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
} else { } else {
menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
} else {
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
}
if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
} else {
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
}
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
} }
if (account.get('id') !== me) { if (account.get('acct') !== account.get('username')) {
menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport }); extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
} }
return ( return (
@ -70,17 +83,17 @@ const ActionBar = React.createClass({
<div style={outerLinksStyle}> <div style={outerLinksStyle}>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
<strong><FormattedNumber value={account.get('statuses_count')} /></strong> <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
</Link> </Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
<strong><FormattedNumber value={account.get('following_count')} /></strong> <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
</Link> </Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
<strong><FormattedNumber value={account.get('followers_count')} /></strong> <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -15,7 +15,8 @@ const Header = React.createClass({
onFollow: React.PropTypes.func.isRequired, onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired,
onReport: React.PropTypes.func.isRequired onReport: React.PropTypes.func.isRequired,
onMute: React.PropTypes.func.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@ -37,6 +38,10 @@ const Header = React.createClass({
this.context.router.push('/report'); this.context.router.push('/report');
}, },
handleMute() {
this.props.onMute(this.props.account);
},
render () { render () {
const { account, me } = this.props; const { account, me } = this.props;
@ -58,6 +63,7 @@ const Header = React.createClass({
onBlock={this.handleBlock} onBlock={this.handleBlock}
onMention={this.handleMention} onMention={this.handleMention}
onReport={this.handleReport} onReport={this.handleReport}
onMute={this.handleMute}
/> />
</div> </div>
); );

View File

@ -5,7 +5,9 @@ import {
followAccount, followAccount,
unfollowAccount, unfollowAccount,
blockAccount, blockAccount,
unblockAccount unblockAccount,
muteAccount,
unmuteAccount
} from '../../../actions/accounts'; } from '../../../actions/accounts';
import { mentionCompose } from '../../../actions/compose'; import { mentionCompose } from '../../../actions/compose';
import { initReport } from '../../../actions/reports'; import { initReport } from '../../../actions/reports';
@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({
onReport (account) { onReport (account) {
dispatch(initReport(account)); dispatch(initReport(account));
},
onMute (account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(muteAccount(account.get('id')));
}
} }
}); });

View File

@ -20,6 +20,8 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token']) accessToken: state.getIn(['meta', 'access_token'])
}); });
let subscription;
const CommunityTimeline = React.createClass({ const CommunityTimeline = React.createClass({
propTypes: { propTypes: {
@ -36,7 +38,11 @@ const CommunityTimeline = React.createClass({
dispatch(refreshTimeline('community')); dispatch(refreshTimeline('community'));
this.subscription = createStream(accessToken, 'public:local', { if (typeof subscription !== 'undefined') {
return;
}
subscription = createStream(accessToken, 'public:local', {
received (data) { received (data) {
switch(data.event) { switch(data.event) {
@ -53,10 +59,10 @@ const CommunityTimeline = React.createClass({
}, },
componentWillUnmount () { componentWillUnmount () {
if (typeof this.subscription !== 'undefined') { // if (typeof subscription !== 'undefined') {
this.subscription.close(); // subscription.close();
this.subscription = null; // subscription = null;
} // }
}, },
render () { render () {

View File

@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
render () { render () {
const diff = this.props.max - this.props.text.length; const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length;
return ( return (
<span style={{ fontSize: '16px', cursor: 'default' }}> <span style={{ fontSize: '16px', cursor: 'default' }}>

View File

@ -15,6 +15,7 @@ import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
import SpoilerToggleContainer from '../containers/spoiler_toggle_container'; import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
import PrivateToggleContainer from '../containers/private_toggle_container'; import PrivateToggleContainer from '../containers/private_toggle_container';
import SensitiveToggleContainer from '../containers/sensitive_toggle_container'; import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
import EmojiPickerDropdown from './emoji_picker_dropdown';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@ -47,6 +48,8 @@ const ComposeForm = React.createClass({
onFetchSuggestions: React.PropTypes.func.isRequired, onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired,
onChangeSpoilerText: React.PropTypes.func.isRequired, onChangeSpoilerText: React.PropTypes.func.isRequired,
onPaste: React.PropTypes.func.isRequired,
onPickEmoji: React.PropTypes.func.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@ -75,6 +78,7 @@ const ComposeForm = React.createClass({
}, },
onSuggestionSelected (tokenStart, token, value) { onSuggestionSelected (tokenStart, token, value) {
this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value); this.props.onSuggestionSelected(tokenStart, token, value);
}, },
@ -87,8 +91,18 @@ const ComposeForm = React.createClass({
// If replying to zero or one users, places the cursor at the end of the textbox. // If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first; // If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation. // this provides a convenient shortcut to drop everyone else from the conversation.
const selectionEnd = this.props.text.length; let selectionEnd, selectionStart;
const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd;
if (this.props.preselectDate !== prevProps.preselectDate) {
selectionEnd = this.props.text.length;
selectionStart = this.props.text.search(/\s/) + 1;
} else if (typeof this._restoreCaret === 'number') {
selectionStart = this._restoreCaret;
selectionEnd = this._restoreCaret;
} else {
selectionEnd = this.props.text.length;
selectionStart = selectionEnd;
}
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus(); this.autosuggestTextarea.textarea.focus();
@ -99,8 +113,14 @@ const ComposeForm = React.createClass({
this.autosuggestTextarea = c; this.autosuggestTextarea = c;
}, },
handleEmojiPick (data) {
const position = this.autosuggestTextarea.textarea.selectionStart;
this._restoreCaret = position + data.shortname.length + 1;
this.props.onPickEmoji(position, data);
},
render () { render () {
const { intl, needsPrivacyWarning, mentionedDomains } = this.props; const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
const disabled = this.props.is_submitting || this.props.is_uploading; const disabled = this.props.is_submitting || this.props.is_uploading;
let publishText = ''; let publishText = '';
@ -149,12 +169,16 @@ const ComposeForm = React.createClass({
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
/> />
<div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
<UploadButtonContainer style={{ paddingTop: '4px' }} /> <div style={{ display: 'flex', paddingTop: '4px' }}>
<UploadButtonContainer />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
</div> </div>
<SpoilerToggleContainer /> <SpoilerToggleContainer />

View File

@ -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);

View File

@ -1,5 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
import { uploadCompose } from '../../../actions/compose';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { import {
changeCompose, changeCompose,
@ -8,6 +9,7 @@ import {
fetchComposeSuggestions, fetchComposeSuggestions,
selectComposeSuggestion, selectComposeSuggestion,
changeComposeSpoilerText, changeComposeSpoilerText,
insertEmojiCompose
} from '../../../actions/compose'; } from '../../../actions/compose';
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
@ -65,6 +67,14 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeComposeSpoilerText(checked)); dispatch(changeComposeSpoilerText(checked));
}, },
onPaste (files) {
dispatch(uploadCompose(files));
},
onPickEmoji (position, data) {
dispatch(insertEmojiCompose(position, data));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);

View File

@ -45,8 +45,7 @@ const GettingStarted = ({ intl, me }) => {
<div className='static-content getting-started'> <div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p>
</div> </div>
</div> </div>
</Column> </Column>

View File

@ -4,7 +4,8 @@ const iconStyle = {
position: 'absolute', position: 'absolute',
right: '48px', right: '48px',
top: '0', top: '0',
cursor: 'pointer' cursor: 'pointer',
zIndex: '2'
}; };
const ClearColumnButton = ({ onClick }) => ( const ClearColumnButton = ({ onClick }) => (

View File

@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
import ClearColumnButton from './components/clear_column_button'; import ClearColumnButton from './components/clear_column_button';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' } title: { id: 'column.notifications', defaultMessage: 'Notifications' },
confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' }
}); });
const getNotifications = createSelector([ const getNotifications = createSelector([
@ -72,7 +73,9 @@ const Notifications = React.createClass({
}, },
handleClear () { handleClear () {
this.props.dispatch(clearNotifications()); if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
this.props.dispatch(clearNotifications());
}
}, },
setRef (c) { setRef (c) {

View File

@ -20,6 +20,8 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token']) accessToken: state.getIn(['meta', 'access_token'])
}); });
let subscription;
const PublicTimeline = React.createClass({ const PublicTimeline = React.createClass({
propTypes: { propTypes: {
@ -36,7 +38,11 @@ const PublicTimeline = React.createClass({
dispatch(refreshTimeline('public')); dispatch(refreshTimeline('public'));
this.subscription = createStream(accessToken, 'public', { if (typeof subscription !== 'undefined') {
return;
}
subscription = createStream(accessToken, 'public', {
received (data) { received (data) {
switch(data.event) { switch(data.event) {
@ -53,10 +59,10 @@ const PublicTimeline = React.createClass({
}, },
componentWillUnmount () { componentWillUnmount () {
if (typeof this.subscription !== 'undefined') { // if (typeof subscription !== 'undefined') {
this.subscription.close(); // subscription.close();
this.subscription = null; // subscription = null;
} // }
}, },
render () { render () {

View File

@ -6,11 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
report: { id: 'status.report', defaultMessage: 'Report' } report: { id: 'status.report', defaultMessage: 'Report @{name}' }
}); });
const ActionBar = React.createClass({ const ActionBar = React.createClass({
@ -66,8 +66,9 @@ const ActionBar = React.createClass({
if (me === status.getIn(['account', 'id'])) { if (me === status.getIn(['account', 'id'])) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport }); menu.push(null);
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }
return ( return (

View File

@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
} else { } else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
} }

View File

@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator'; import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
media: state.getIn(['modal', 'media']), media: state.getIn(['modal', 'media']),
@ -131,27 +132,34 @@ const Modal = React.createClass({
return null; return null;
} }
const url = media.get(index).get('url'); const attachment = media.get(index);
const url = attachment.get('url');
let leftNav, rightNav; let leftNav, rightNav, content;
leftNav = rightNav = ''; leftNav = rightNav = content = '';
if (media.size > 1) { if (media.size > 1) {
leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
} }
return ( if (attachment.get('type') === 'image') {
<Lightbox {...other}> content = (
{leftNav}
<ImageLoader <ImageLoader
src={url} src={url}
preloader={preloader} preloader={preloader}
imgProps={{ style: imageStyle }} imgProps={{ style: imageStyle }}
/> />
);
} else if (attachment.get('type') === 'gifv') {
content = <ExtendedVideoPlayer src={url} />;
}
return (
<Lightbox {...other}>
{leftNav}
{content}
{rightNav} {rightNav}
</Lightbox> </Lightbox>
); );

View File

@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
export function isMobile(width) { export function isMobile(width) {
return width <= LAYOUT_BREAKPOINT; return width <= LAYOUT_BREAKPOINT;
}; };
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
export function isIOS() {
return iOS;
};

View File

@ -2,7 +2,7 @@ const en = {
"column_back_button.label": "Back", "column_back_button.label": "Back",
"lightbox.close": "Close", "lightbox.close": "Close",
"loading_indicator.label": "Loading...", "loading_indicator.label": "Loading...",
"status.mention": "Mention", "status.mention": "Mention @{name}",
"status.delete": "Delete", "status.delete": "Delete",
"status.reply": "Reply", "status.reply": "Reply",
"status.reblog": "Boost", "status.reblog": "Boost",
@ -11,11 +11,11 @@ const en = {
"status.sensitive_warning": "Sensitive content", "status.sensitive_warning": "Sensitive content",
"status.sensitive_toggle": "Click to view", "status.sensitive_toggle": "Click to view",
"video_player.toggle_sound": "Toggle sound", "video_player.toggle_sound": "Toggle sound",
"account.mention": "Mention", "account.mention": "Mention @{name}",
"account.edit_profile": "Edit profile", "account.edit_profile": "Edit profile",
"account.unblock": "Unblock", "account.unblock": "Unblock @{name}",
"account.unfollow": "Unfollow", "account.unfollow": "Unfollow",
"account.block": "Block", "account.block": "Block @{name}",
"account.follow": "Follow", "account.follow": "Follow",
"account.posts": "Posts", "account.posts": "Posts",
"account.follows": "Follows", "account.follows": "Follows",
@ -25,16 +25,15 @@ const en = {
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.", "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.", "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
"getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social", "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
"column.home": "Home", "column.home": "Home",
"column.community": "Local", "column.community": "Local timeline",
"column.public": "Whole Known Network", "column.public": "Federated timeline",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"tabs_bar.compose": "Compose", "tabs_bar.compose": "Compose",
"tabs_bar.home": "Home", "tabs_bar.home": "Home",
"tabs_bar.mentions": "Mentions", "tabs_bar.mentions": "Mentions",
"tabs_bar.public": "Whole Known Network", "tabs_bar.public": "Federated timeline",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"compose_form.placeholder": "What is on your mind?", "compose_form.placeholder": "What is on your mind?",
"compose_form.publish": "Toot", "compose_form.publish": "Toot",
@ -46,7 +45,7 @@ const en = {
"navigation_bar.edit_profile": "Edit profile", "navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
"navigation_bar.community_timeline": "Local timeline", "navigation_bar.community_timeline": "Local timeline",
"navigation_bar.public_timeline": "Whole Known Network", "navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",
"reply_indicator.cancel": "Cancel", "reply_indicator.cancel": "Cancel",
"search.placeholder": "Search", "search.placeholder": "Search",

View File

@ -0,0 +1,22 @@
const play = audio => {
if (!audio.paused) {
audio.pause();
audio.fastSeek(0);
}
audio.play();
};
export default function soundsMiddleware() {
const soundCache = {
boop: new Audio(['/sounds/boop.mp3'])
};
return ({ dispatch }) => next => (action) => {
if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
play(soundCache[action.meta.sound]);
}
return next(action);
};
};

View File

@ -20,7 +20,8 @@ import {
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE, COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LISTABILITY_CHANGE COMPOSE_LISTABILITY_CHANGE,
COMPOSE_EMOJI_INSERT
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
@ -105,6 +106,15 @@ const insertSuggestion = (state, position, token, completion) => {
}); });
}; };
const insertEmoji = (state, position, emojiData) => {
const emoji = emojiData.shortname;
return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
map.set('focusDate', new Date());
});
};
export default function compose(state = initialState, action) { export default function compose(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
@ -177,6 +187,8 @@ export default function compose(state = initialState, action) {
} else { } else {
return state; return state;
} }
case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji);
default: default:
return state; return state;
} }

View File

@ -3,6 +3,8 @@ import {
ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
ACCOUNT_UNMUTE_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS RELATIONSHIPS_FETCH_SUCCESS
} from '../actions/accounts'; } from '../actions/accounts';
import Immutable from 'immutable'; import Immutable from 'immutable';
@ -25,6 +27,8 @@ export default function relationships(state = initialState, action) {
case ACCOUNT_UNFOLLOW_SUCCESS: case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS: case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
case ACCOUNT_UNMUTE_SUCCESS:
return normalizeRelationship(state, action.relationship); return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS: case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships); return normalizeRelationships(state, action.relationships);

View File

@ -22,7 +22,8 @@ import {
ACCOUNT_TIMELINE_EXPAND_REQUEST, ACCOUNT_TIMELINE_EXPAND_REQUEST,
ACCOUNT_TIMELINE_EXPAND_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_FAIL, ACCOUNT_TIMELINE_EXPAND_FAIL,
ACCOUNT_BLOCK_SUCCESS ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
CONTEXT_FETCH_SUCCESS CONTEXT_FETCH_SUCCESS
@ -295,6 +296,7 @@ export default function timelines(state = initialState, action) {
case ACCOUNT_TIMELINE_EXPAND_SUCCESS: case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses); return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);

View File

@ -0,0 +1,27 @@
// U+0590 to U+05FF - Hebrew
// U+0600 to U+06FF - Arabic
// U+0700 to U+074F - Syriac
// U+0750 to U+077F - Arabic Supplement
// U+0780 to U+07BF - Thaana
// U+07C0 to U+07FF - N'Ko
// U+0800 to U+083F - Samaritan
// U+08A0 to U+08FF - Arabic Extended-A
// U+FB1D to U+FB4F - Hebrew presentation forms
// U+FB50 to U+FDFF - Arabic presentation forms A
// U+FE70 to U+FEFF - Arabic presentation forms B
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
export function isRtl(text) {
if (text.length === 0) {
return false;
}
const matches = text.match(rtlChars);
if (!matches) {
return false;
}
return matches.length / text.trim().length > 0.3;
};

View File

@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
import appReducer from '../reducers'; import appReducer from '../reducers';
import loadingBarMiddleware from '../middleware/loading_bar'; import loadingBarMiddleware from '../middleware/loading_bar';
import errorsMiddleware from '../middleware/errors'; import errorsMiddleware from '../middleware/errors';
import soundsMiddleware from 'redux-sounds'; import soundsMiddleware from '../middleware/sounds';
import Howler from 'howler';
import Immutable from 'immutable'; import Immutable from 'immutable';
Howler.mobileAutoEnable = false;
const soundsData = {
boop: '/sounds/boop.mp3'
};
export default function configureStore() { export default function configureStore() {
return createStore(appReducer, compose(applyMiddleware( return createStore(appReducer, compose(applyMiddleware(
thunk, thunk,
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
errorsMiddleware(), errorsMiddleware(),
soundsMiddleware(soundsData) soundsMiddleware()
), window.devToolsExtension ? window.devToolsExtension() : f => f)); ), window.devToolsExtension ? window.devToolsExtension() : f => f));
}; };

View File

@ -1,3 +1,5 @@
@import 'variables';
.button { .button {
background-color: darken($color4, 3%); background-color: darken($color4, 3%);
font-family: inherit; font-family: inherit;
@ -59,6 +61,14 @@
&.active { &.active {
color: $color4; color: $color4;
} }
&:focus {
outline: none;
}
}
.dropdown--active .icon-button {
color: $color4;
} }
.invisible { .invisible {
@ -387,6 +397,10 @@ a.status__content__spoiler-link {
font-weight: 500; font-weight: 500;
color: $color5; color: $color5;
} }
abbr {
color: lighten($color1, 26%);
}
} }
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name { .status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
@ -516,6 +530,12 @@ a.status__content__spoiler-link {
position: absolute; position: absolute;
} }
.dropdown__sep {
border-bottom: 1px solid darken($color2, 8%);
margin: 5px 7px 6px;
padding-top: 1px;
}
.dropdown--active .dropdown__content { .dropdown--active .dropdown__content {
display: block; display: block;
z-index: 9999; z-index: 9999;
@ -533,23 +553,40 @@ a.status__content__spoiler-link {
left: 8px; left: 8px;
} }
ul { & > ul {
list-style: none; list-style: none;
background: $color2; background: $color2;
padding: 4px 0; padding: 4px 0;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 0 15px rgba($color8, 0.4); box-shadow: 0 0 15px rgba($color8, 0.4);
min-width: 100px; min-width: 140px;
position: relative;
left: -10px;
} }
a { &.dropdown__left {
& > ul {
left: -98px;
}
}
& > ul > li > a {
font-size: 13px; font-size: 13px;
line-height: 18px;
display: block; display: block;
padding: 6px 16px; padding: 4px 14px;
width: 100px; box-sizing: border-box;
width: 140px;
text-decoration: none; text-decoration: none;
background: $color2; background: $color2;
color: $color1; color: $color1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:focus {
outline: none;
}
&:hover { &:hover {
background: $color4; background: $color4;
@ -983,15 +1020,6 @@ a.status__content__spoiler-link {
} }
} }
.dropdown__content.dropdown__left {
transform: translateX(-108px);
&::before {
right: 8px !important;
left: initial !important;
}
}
.setting-text { .setting-text {
color: $color3; color: $color3;
background: transparent; background: transparent;
@ -1074,8 +1102,10 @@ button.active i.fa-retweet {
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: lighten($color1, 26%); color: lighten($color1, 16%);
padding-top: 120px; padding-top: 210px;
background: image-url('mastodon-not-found.png') no-repeat center -50px;
cursor: default;
} }
.column-header { .column-header {
@ -1230,3 +1260,164 @@ button.active i.fa-retweet {
z-index: 1; z-index: 1;
background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%); background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
} }
.emoji-dialog {
width: 280px;
height: 220px;
background: $color2;
box-sizing: border-box;
border-radius: 2px;
overflow: hidden;
position: relative;
box-shadow: 0 0 15px rgba($color8, 0.4);
.emojione {
margin: 0;
}
.emoji-dialog-header {
padding: 0 10px;
background-color: $color3;
ul {
padding: 0;
margin: 0;
list-style: none;
}
li {
display: inline-block;
box-sizing: border-box;
height: 42px;
padding: 9px 5px;
cursor: pointer;
img, svg {
width: 22px;
height: 22px;
filter: grayscale(100%);
}
&.active {
background: lighten($color3, 6%);
img, svg {
filter: grayscale(0);
}
}
}
}
.emoji-row {
box-sizing: border-box;
overflow-y: hidden;
padding-left: 10px;
.emoji {
display: inline-block;
padding: 5px;
border-radius: 4px;
}
}
.emoji-category-header {
box-sizing: border-box;
overflow-y: hidden;
padding: 8px 16px 0;
display: table;
> * {
display: table-cell;
vertical-align: middle;
}
}
.emoji-category-title {
font-size: 14px;
font-family: sans-serif;
font-weight: normal;
color: $color1;
cursor: default;
}
.emoji-category-heading-decoration {
text-align: right;
}
.modifiers {
list-style: none;
padding: 0;
margin: 0;
vertical-align: middle;
white-space: nowrap;
margin-top: 4px;
li {
display: inline-block;
padding: 0 2px;
&:last-of-type {
padding-right: 0;
}
}
.modifier {
display: inline-block;
border-radius: 10px;
width: 15px;
height: 15px;
position: relative;
cursor: pointer;
&.active:after {
content: "";
display: block;
position: absolute;
width: 7px;
height: 7px;
border-radius: 10px;
border: 2px solid $color1;
top: 2px;
left: 2px;
}
}
}
.emoji-search-wrapper {
padding: 6px 16px;
}
.emoji-search {
font-size: 12px;
padding: 6px 4px;
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
}
.emoji-categories-wrapper {
position: absolute;
top: 42px;
bottom: 0;
left: 0;
right: 0;
}
.emoji-search-wrapper + .emoji-categories-wrapper {
top: 83px;
}
.emoji-row .emoji:hover {
background: lighten($color2, 3%);
}
.emoji {
width: 22px;
height: 22px;
cursor: pointer;
&:focus {
outline: none;
}
}
}

View File

@ -104,8 +104,12 @@
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
height: 110px; position: relative;
display: flex;
.status__attachments__inner {
display: flex;
height: 214px;
}
} }
} }
@ -184,8 +188,12 @@
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
height: 300px; position: relative;
display: flex;
.status__attachments__inner {
display: flex;
height: 360px;
}
} }
.video-player { .video-player {
@ -231,11 +239,19 @@
text-decoration: none; text-decoration: none;
cursor: zoom-in; cursor: zoom-in;
} }
video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
top: 50%;
transform: translateY(-50%);
}
} }
.video-item { .video-item {
max-width: 196px;
a { a {
cursor: pointer; cursor: pointer;
} }
@ -258,6 +274,9 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
position: absolute;
top: 0;
left: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::AccountsController < ApiController class Api::V1::AccountsController < ApiController
before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock] before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock] before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
before_action :require_user!, except: [:show, :following, :followers, :statuses] before_action :require_user!, except: [:show, :following, :followers, :statuses]
before_action :set_account, except: [:verify_credentials, :suggestions, :search] before_action :set_account, except: [:verify_credentials, :suggestions, :search]
@ -47,10 +47,13 @@ class Api::V1::AccountsController < ApiController
def statuses def statuses
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
@statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media]
@statuses = @statuses.without_replies if params[:exclude_replies]
@statuses = cache_collection(@statuses, Status) @statuses = cache_collection(@statuses, Status)
set_maps(@statuses) set_maps(@statuses)
set_counters_maps(@statuses) set_counters_maps(@statuses)
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty? next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty?
prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty? prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
@ -58,21 +61,6 @@ class Api::V1::AccountsController < ApiController
set_pagination_headers(next_path, prev_path) set_pagination_headers(next_path, prev_path)
end end
def media_statuses
media_ids = MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')
@statuses = @account.statuses.where(id: media_ids).permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
set_maps(@statuses)
set_counters_maps(@statuses)
next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id) unless @statuses.empty?
prev_path = media_statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
set_pagination_headers(next_path, prev_path)
render action: :statuses
end
def follow def follow
FollowService.new.call(current_user.account, @account.acct) FollowService.new.call(current_user.account, @account.acct)
set_relationship set_relationship
@ -86,10 +74,17 @@ class Api::V1::AccountsController < ApiController
@followed_by = { @account.id => false } @followed_by = { @account.id => false }
@blocking = { @account.id => true } @blocking = { @account.id => true }
@requested = { @account.id => false } @requested = { @account.id => false }
@muting = { @account.id => current_user.account.muting?(@account.id) }
render action: :relationship render action: :relationship
end end
def mute
MuteService.new.call(current_user.account, @account)
set_relationship
render action: :relationship
end
def unfollow def unfollow
UnfollowService.new.call(current_user.account, @account) UnfollowService.new.call(current_user.account, @account)
set_relationship set_relationship
@ -102,6 +97,12 @@ class Api::V1::AccountsController < ApiController
render action: :relationship render action: :relationship
end end
def unmute
UnmuteService.new.call(current_user.account, @account)
set_relationship
render action: :relationship
end
def relationships def relationships
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
@ -109,6 +110,7 @@ class Api::V1::AccountsController < ApiController
@following = Account.following_map(ids, current_user.account_id) @following = Account.following_map(ids, current_user.account_id)
@followed_by = Account.followed_by_map(ids, current_user.account_id) @followed_by = Account.followed_by_map(ids, current_user.account_id)
@blocking = Account.blocking_map(ids, current_user.account_id) @blocking = Account.blocking_map(ids, current_user.account_id)
@muting = Account.muting_map(ids, current_user.account_id)
@requested = Account.requested_map(ids, current_user.account_id) @requested = Account.requested_map(ids, current_user.account_id)
end end
@ -130,6 +132,7 @@ class Api::V1::AccountsController < ApiController
@following = Account.following_map([@account.id], current_user.account_id) @following = Account.following_map([@account.id], current_user.account_id)
@followed_by = Account.followed_by_map([@account.id], current_user.account_id) @followed_by = Account.followed_by_map([@account.id], current_user.account_id)
@blocking = Account.blocking_map([@account.id], current_user.account_id) @blocking = Account.blocking_map([@account.id], current_user.account_id)
@muting = Account.muting_map([@account.id], current_user.account_id)
@requested = Account.requested_map([@account.id], current_user.account_id) @requested = Account.requested_map([@account.id], current_user.account_id)
end end
end end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Api::V1::MutesController < ApiController
before_action -> { doorkeeper_authorize! :follow }
before_action :require_user!
respond_to :json
def index
results = Mute.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
@accounts = results.map { |f| accounts[f.target_account_id] }
set_account_counters_maps(@accounts)
next_path = api_v1_mutes_url(max_id: results.last.id) if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty?
set_pagination_headers(next_path, prev_path)
end
end

View File

@ -79,6 +79,7 @@ class ApiController < ApplicationController
def require_user! def require_user!
current_resource_owner current_resource_owner
set_user_activity
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: { error: 'This method requires an authenticated user' }, status: 422 render json: { error: 'This method requires an authenticated user' }, status: 422
end end

View File

@ -13,6 +13,10 @@ module ObfuscateFilename
file = params.dig(*path) file = params.dig(*path)
return if file.nil? return if file.nil?
file.original_filename = 'media' + File.extname(file.original_filename) file.original_filename = secure_token + File.extname(file.original_filename)
end
def secure_token(length = 16)
SecureRandom.hex(length / 2)
end end
end end

View File

@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController
reblog: user_params[:notification_emails][:reblog] == '1', reblog: user_params[:notification_emails][:reblog] == '1',
favourite: user_params[:notification_emails][:favourite] == '1', favourite: user_params[:notification_emails][:favourite] == '1',
mention: user_params[:notification_emails][:mention] == '1', mention: user_params[:notification_emails][:mention] == '1',
digest: user_params[:notification_emails][:digest] == '1',
} }
current_user.settings['interactions'] = { current_user.settings['interactions'] = {
@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController
private private
def user_params def user_params
params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
end end
end end

View File

@ -37,4 +37,17 @@ module StreamEntriesHelper
def proper_status(status) def proper_status(status)
status.reblog? ? status.reblog : status status.reblog? ? status.reblog : status
end end
def rtl?(text)
return false if text.empty?
matches = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text)
return false unless matches
rtl_size = matches.to_a.reduce(0) { |acc, elem| acc + elem.size }.to_f
ltr_size = text.strip.size.to_f
rtl_size / ltr_size > 0.3
end
end end

View File

@ -22,8 +22,18 @@ class FeedManager
end end
def push(timeline_type, account, status) def push(timeline_type, account, status)
redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) timeline_key = key(timeline_type, account.id)
trim(timeline_type, account.id)
if status.reblog?
# If the original status is within 40 statuses from top, do not re-insert it into the feed
rank = redis.zrevrank(timeline_key, status.reblog_of_id)
return if !rank.nil? && rank < 40
redis.zadd(timeline_key, status.id, status.reblog_of_id)
else
redis.zadd(timeline_key, status.id, status.id)
trim(timeline_type, account.id)
end
broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status)) broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
end end
@ -85,6 +95,8 @@ class FeedManager
end end
def filter_from_home?(status, receiver) def filter_from_home?(status, receiver)
return true if receiver.muting?(status.account)
should_filter = false should_filter = false
if status.reply? && status.in_reply_to_id.nil? if status.reply? && status.in_reply_to_id.nil?
@ -95,6 +107,7 @@ class FeedManager
should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
elsif status.reblog? # Filter out a reblog elsif status.reblog? # Filter out a reblog
should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person
should_filter ||= receiver.muting?(status.reblog.account) # or muting that person
end end
should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked

View File

@ -29,6 +29,11 @@ class Formatter
sanitize(html, tags: %w(a br p span), attributes: %w(href rel class)) sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
end end
def plaintext(status)
return status.text if status.local?
strip_tags(status.text)
end
def simplified_format(account) def simplified_format(account)
return reformat(account.note) unless account.local? return reformat(account.note) unless account.local?

View File

@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
end end
end end
def digest(recipient, opts = {})
@me = recipient
@since = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at
@notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since)
@follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count
return if @notifications.empty?
I18n.with_locale(@me.user.locale || I18n.default_locale) do
mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size)
end
end
end end

View File

@ -4,7 +4,7 @@ class Account < ApplicationRecord
include Targetable include Targetable
include PgSearch include PgSearch
MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
# Local users # Local users
@ -46,6 +46,10 @@ class Account < ApplicationRecord
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
# Mute relationships
has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
# Media # Media
has_many :media_attachments, dependent: :destroy has_many :media_attachments, dependent: :destroy
@ -73,6 +77,10 @@ class Account < ApplicationRecord
block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
end end
def mute!(other_account)
mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
end
def unfollow!(other_account) def unfollow!(other_account)
follow = active_relationships.find_by(target_account: other_account) follow = active_relationships.find_by(target_account: other_account)
follow&.destroy follow&.destroy
@ -83,6 +91,11 @@ class Account < ApplicationRecord
block&.destroy block&.destroy
end end
def unmute!(other_account)
mute = mute_relationships.find_by(target_account: other_account)
mute&.destroy
end
def following?(other_account) def following?(other_account)
following.include?(other_account) following.include?(other_account)
end end
@ -91,6 +104,10 @@ class Account < ApplicationRecord
blocking.include?(other_account) blocking.include?(other_account)
end end
def muting?(other_account)
muting.include?(other_account)
end
def requested?(other_account) def requested?(other_account)
follow_requests.where(target_account: other_account).exists? follow_requests.where(target_account: other_account).exists?
end end
@ -188,6 +205,10 @@ class Account < ApplicationRecord
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end end
def muting_map(target_account_ids, account_id)
follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def requested_map(target_account_ids, account_id) def requested_map(target_account_ids, account_id)
follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end end

View File

@ -1,15 +1,32 @@
# frozen_string_literal: true # frozen_string_literal: true
class MediaAttachment < ApplicationRecord class MediaAttachment < ApplicationRecord
self.inheritance_column = nil
enum type: [:image, :gifv, :video]
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
VIDEO_STYLES = {
small: {
convert_options: {
output: {
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
},
},
format: 'png',
time: 0,
},
}.freeze
belongs_to :account, inverse_of: :media_attachments belongs_to :account, inverse_of: :media_attachments
belongs_to :status, inverse_of: :media_attachments belongs_to :status, inverse_of: :media_attachments
has_attached_file :file, has_attached_file :file,
styles: -> (f) { file_styles f }, styles: ->(f) { file_styles f },
processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] }, processors: ->(f) { file_processors f },
convert_options: { all: '-quality 90 -strip' } convert_options: { all: '-quality 90 -strip' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
validates_attachment_size :file, less_than: 8.megabytes validates_attachment_size :file, less_than: 8.megabytes
@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
self.file = URI.parse(url) self.file = URI.parse(url)
end end
def image?
IMAGE_MIME_TYPES.include? file_content_type
end
def video?
VIDEO_MIME_TYPES.include? file_content_type
end
def type
image? ? 'image' : 'video'
end
def to_param def to_param
shortcode shortcode
end end
before_create :set_shortcode before_create :set_shortcode
before_post_process :set_type
class << self class << self
private private
def file_styles(f) def file_styles(f)
if f.instance.image? if f.instance.file_content_type == 'image/gif'
{ {
original: '1280x1280>', small: IMAGE_STYLES[:small],
small: '400x400>', original: {
} format: 'mp4',
else
{
small: {
convert_options: { convert_options: {
output: { output: {
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', 'movflags' => 'faststart',
'pix_fmt' => 'yuv420p',
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
'vsync' => 'cfr',
'b:v' => '1300K',
'maxrate' => '500K',
'crf' => 6,
}, },
}, },
format: 'png',
time: 1,
}, },
} }
elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
IMAGE_STYLES
else
VIDEO_STYLES
end
end
def file_processors(f)
if f.file_content_type == 'image/gif'
[:gif_transcoder]
elsif VIDEO_MIME_TYPES.include? f.file_content_type
[:video_transcoder]
else
[:thumbnail]
end end
end end
end end
@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
break if MediaAttachment.find_by(shortcode: shortcode).nil? break if MediaAttachment.find_by(shortcode: shortcode).nil?
end end
end end
def set_type
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
end
end end

11
app/models/mute.rb Normal file
View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Mute < ApplicationRecord
include Paginable
belongs_to :account
belongs_to :target_account, class_name: 'Account'
validates :account, :target_account, presence: true
validates :account_id, uniqueness: { scope: :target_account_id }
end

View File

@ -2,7 +2,6 @@
class Setting < RailsSettings::Base class Setting < RailsSettings::Base
source Rails.root.join('config/settings.yml') source Rails.root.join('config/settings.yml')
namespace Rails.env
def to_param def to_param
var var

View File

@ -37,6 +37,9 @@ class Status < ApplicationRecord
scope :remote, -> { where.not(uri: nil) } scope :remote, -> { where.not(uri: nil) }
scope :local, -> { where(uri: nil) } scope :local, -> { where(uri: nil) }
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
def reply? def reply?
@ -109,8 +112,8 @@ class Status < ApplicationRecord
def as_public_timeline(account = nil, local_only = false) def as_public_timeline(account = nil, local_only = false)
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
.where(visibility: :public) .where(visibility: :public)
.where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)') .without_replies
.where('statuses.reblog_of_id IS NULL') .without_reblogs
query = query.where('accounts.domain IS NULL') if local_only query = query.where('accounts.domain IS NULL') if local_only
@ -121,7 +124,7 @@ class Status < ApplicationRecord
query = tag.statuses query = tag.statuses
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
.where(visibility: :public) .where(visibility: :public)
.where('statuses.reblog_of_id IS NULL') .without_reblogs
query = query.where('accounts.domain IS NULL') if local_only query = query.where('accounts.domain IS NULL') if local_only
@ -168,9 +171,9 @@ class Status < ApplicationRecord
private private
def filter_timeline(query, account) def filter_timeline(query, account)
blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id)
query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? # Only give us statuses from people we haven't blocked, or muted, or that have blocked us
query = query.where('accounts.silenced = TRUE') if account.silenced? query = query.where('accounts.silenced = TRUE') if account.silenced? # and if we're hellbanned, only people who are also hellbanned
query query
end end
@ -192,6 +195,6 @@ class Status < ApplicationRecord
private private
def filter_from_context?(status, account) def filter_from_context?(status, account)
account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
end end
end end

View File

@ -3,7 +3,7 @@
class Tag < ApplicationRecord class Tag < ApplicationRecord
has_and_belongs_to_many :statuses has_and_belongs_to_many :statuses
HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
validates :name, presence: true, uniqueness: true validates :name, presence: true, uniqueness: true

View File

@ -14,9 +14,10 @@ class User < ApplicationRecord
validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?' validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
validates :email, email: true validates :email, email: true
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
scope :recent, -> { order('id desc') } scope :recent, -> { order('id desc') }
scope :admins, -> { where(admin: true) } scope :admins, -> { where(admin: true) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
def send_devise_notification(notification, *args) def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later devise_mailer.send(notification, self, *args).deliver_later

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class MuteService < BaseService
def call(account, target_account)
return if account.id == target_account.id
clear_home_timeline(account, target_account)
account.mute!(target_account)
end
private
def clear_home_timeline(account, target_account)
home_key = FeedManager.instance.key(:home, account.id)
target_account.statuses.select('id').find_each do |status|
redis.zrem(home_key, status.id)
end
end
def redis
Redis.current
end
end

View File

@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
status.save! status.save!
NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local? notify_about_mentions!(status) unless status.reblog?
notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
DistributionWorker.perform_async(status.id) DistributionWorker.perform_async(status.id)
status status
end end
def notify_about_mentions!(status)
status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account
next unless mentioned_account.local?
NotifyService.new.call(mentioned_account, mention)
end
end
def notify_about_reblog!(status)
NotifyService.new.call(status.reblog.account, status)
end
def delete_status def delete_status
Rails.logger.debug "Deleting remote status #{id}" Rails.logger.debug "Deleting remote status #{id}"
status = Status.find_by(uri: id) status = Status.find_by(uri: id)
@ -159,10 +172,7 @@ class ProcessFeedService < BaseService
next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id) next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent) mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
# Notify local user
NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
# So we can skip duplicate mentions # So we can skip duplicate mentions
processed_account_ids << mentioned_account.id processed_account_ids << mentioned_account.id

View File

@ -27,7 +27,7 @@ class ProcessMentionsService < BaseService
mentioned_account.mentions.where(status: status).first_or_create(status: status) mentioned_account.mentions.where(status: status).first_or_create(status: status)
end end
status.mentions.each do |mention| status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account mentioned_account = mention.account
if mentioned_account.local? if mentioned_account.local?

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class UnmuteService < BaseService
def call(account, target_account)
return unless account.muting?(target_account)
account.unmute!(target_account)
MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
end
end

View File

@ -4,4 +4,5 @@ attribute :id
node(:following) { |account| @following[account.id] || false } node(:following) { |account| @following[account.id] || false }
node(:followed_by) { |account| @followed_by[account.id] || false } node(:followed_by) { |account| @followed_by[account.id] || false }
node(:blocking) { |account| @blocking[account.id] || false } node(:blocking) { |account| @blocking[account.id] || false }
node(:muting) { |account| @muting[account.id] || false }
node(:requested) { |account| @requested[account.id] || false } node(:requested) { |account| @requested[account.id] || false }

View File

@ -1,5 +1,5 @@
object @media object @media
attribute :id, :type attribute :id, :type
node(:url) { |media| full_asset_url(media.file.url( :original)) } node(:url) { |media| full_asset_url(media.file.url(:original)) }
node(:preview_url) { |media| full_asset_url(media.file.url( :small)) } node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
node(:text_url) { |media| medium_url(media) } node(:text_url) { |media| medium_url(media) }

View File

@ -0,0 +1,2 @@
collection @accounts
extends 'api/v1/accounts/show'

View File

@ -1,5 +1,5 @@
<%= yield %> <%= yield %>
--- ---
<%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %> <%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
<%= t('application_mailer.settings', link: settings_preferences_url) %>

View File

@ -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}") %>

View File

@ -0,0 +1,15 @@
<%= display_name(@me) %>,
<%= raw t('notification_mailer.digest.body', since: @since, instance: root_url) %>
<% @notifications.each do |notification| %>
* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.acct) %>
<%= raw Formatter.instance.plaintext(notification.target_status) %>
<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
<% end %>
<% if @follows_since > 0 %>
<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %>
<% end %>

View File

@ -1,5 +1,5 @@
<%= display_name(@me) %>, <%= display_name(@me) %>,
<%= t('notification_mailer.favourite.body', name: @account.acct) %> <%= raw t('notification_mailer.favourite.body', name: @account.acct) %>
<%= render partial: 'status' %> <%= render partial: 'status', locals: { status: @status } %>

View File

@ -1,5 +1,5 @@
<%= display_name(@me) %>, <%= display_name(@me) %>,
<%= t('notification_mailer.follow.body', name: @account.acct) %> <%= raw t('notification_mailer.follow.body', name: @account.acct) %>
<%= web_url("accounts/#{@account.id}") %> <%= raw t('application_mailer.view')%> <%= web_url("accounts/#{@account.id}") %>

View File

@ -1,5 +1,5 @@
<%= display_name(@me) %>, <%= display_name(@me) %>,
<%= t('notification_mailer.follow_request.body', name: @account.acct) %> <%= raw t('notification_mailer.follow_request.body', name: @account.acct) %>
<%= web_url("follow_requests") %> <%= raw t('application_mailer.view')%> <%= web_url("follow_requests") %>

View File

@ -1,5 +1,5 @@
<%= display_name(@me) %>, <%= display_name(@me) %>,
<%= t('notification_mailer.mention.body', name: @status.account.acct) %> <%= raw t('notification_mailer.mention.body', name: @status.account.acct) %>
<%= render partial: 'status' %> <%= render partial: 'status', locals: { status: @status } %>

View File

@ -1,5 +1,5 @@
<%= display_name(@me) %>, <%= display_name(@me) %>,
<%= t('notification_mailer.reblog.body', name: @account.acct) %> <%= raw t('notification_mailer.reblog.body', name: @account.acct) %>
<%= render partial: 'status' %> <%= render partial: 'status', locals: { status: @status } %>

View File

@ -16,6 +16,7 @@
= ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :reblog, as: :boolean, wrapper: :with_label
= ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label
= ff.input :mention, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label
= ff.input :digest, as: :boolean, wrapper: :with_label
= f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
= ff.input :must_be_follower, as: :boolean, wrapper: :with_label = ff.input :must_be_follower, as: :boolean, wrapper: :with_label

View File

@ -10,7 +10,7 @@
.status__content.e-content.p-name.emojify< .status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank? - unless status.spoiler_text.blank?
%p= status.spoiler_text %p= status.spoiler_text
= Formatter.instance.format(status) %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty? - unless status.media_attachments.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
@ -22,9 +22,9 @@
.detailed-status__attachments .detailed-status__attachments
- if status.sensitive? - if status.sensitive?
= render partial: 'stream_entries/content_spoiler' = render partial: 'stream_entries/content_spoiler'
- status.media_attachments.each do |media| .status__attachments__inner
.media-item - status.media_attachments.each do |media|
= link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" = render partial: 'stream_entries/media', locals: { media: media }
%div.detailed-status__meta %div.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 } %data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@ -0,0 +1,4 @@
.media-item
= link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
- unless media.image?
%video{ src: media.file.url(:original), autoplay: true, loop: true }/

View File

@ -15,18 +15,19 @@
.status__content.e-content.p-name.emojify< .status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank? - unless status.spoiler_text.blank?
%p= status.spoiler_text %p= status.spoiler_text
= Formatter.instance.format(status) %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty? - unless status.media_attachments.empty?
.status__attachments .status__attachments
- if status.sensitive? - if status.sensitive?
= render partial: 'stream_entries/content_spoiler' = render partial: 'stream_entries/content_spoiler'
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
.video-item .status__attachments__inner
= link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do .video-item
.video-item__play = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
= fa_icon('play') .video-item__play
= fa_icon('play')
- else - else
- status.media_attachments.each do |media| .status__attachments__inner
.media-item - status.media_attachments.each do |media|
= link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}" = render partial: 'stream_entries/media', locals: { media: media }

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class DigestMailerWorker
include Sidekiq::Worker
sidekiq_options queue: 'mailers'
def perform(user_id)
user = User.find(user_id)
return unless user.settings.notification_emails['digest']
NotificationMailer.digest(user.account).deliver_now!
user.touch(:last_emailed_at)
end
end

View File

@ -2,12 +2,14 @@ require_relative 'boot'
require 'rails/all' require 'rails/all'
require_relative '../app/lib/exceptions'
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups) Bundler.require(*Rails.groups)
require_relative '../app/lib/exceptions'
require_relative '../lib/paperclip/gif_transcoder'
require_relative '../lib/paperclip/video_transcoder'
Dotenv::Railtie.load Dotenv::Railtie.load
module Mastodon module Mastodon
@ -49,12 +51,5 @@ module Mastodon
Doorkeeper::AuthorizedApplicationsController.layout 'admin' Doorkeeper::AuthorizedApplicationsController.layout 'admin'
Doorkeeper::Application.send :include, ApplicationExtension Doorkeeper::Application.send :include, ApplicationExtension
end end
config.action_dispatch.default_headers = {
'Server' => 'Mastodon',
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
}
end end
end end

View File

@ -109,4 +109,11 @@ Rails.application.configure do
config.to_prepare do config.to_prepare do
StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank? StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
end end
config.action_dispatch.default_headers = {
'Server' => 'Mastodon',
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
}
end end

View File

@ -2,6 +2,11 @@
Paperclip.options[:read_timeout] = 60 Paperclip.options[:read_timeout] = 60
Paperclip.interpolates :filename do |attachment, style|
return attachment.original_filename if style == :original
[basename(attachment, style), extension(attachment, style)].delete_if(&:empty?).join('.')
end
if ENV['S3_ENABLED'] == 'true' if ENV['S3_ENABLED'] == 'true'
Aws.eager_autoload!(services: %w(S3)) Aws.eager_autoload!(services: %w(S3))

View File

@ -1,6 +1,6 @@
Rabl.configure do |config| Rabl.configure do |config|
config.cache_all_output = false config.cache_all_output = false
config.cache_sources = !!Rails.env.production? config.cache_sources = Rails.env.production?
config.include_json_root = false config.include_json_root = false
config.view_paths = [Rails.root.join('app/views')] config.view_paths = [Rails.root.join('app/views')]
end end

View File

@ -1,6 +1,6 @@
class Rack::Attack class Rack::Attack
# Rate limits for the API # Rate limits for the API
throttle('api', limit: 150, period: 5.minutes) do |req| throttle('api', limit: 300, period: 5.minutes) do |req|
req.ip if req.path.match(/\A\/api\/v/) req.ip if req.path.match(/\A\/api\/v/)
end end
@ -11,7 +11,7 @@ class Rack::Attack
headers = { headers = {
'X-RateLimit-Limit' => match_data[:limit].to_s, 'X-RateLimit-Limit' => match_data[:limit].to_s,
'X-RateLimit-Remaining' => '0', 'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6) 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6),
} }
[429, headers, [{ error: 'Throttled' }.to_json]] [429, headers, [{ error: 'Throttled' }.to_json]]

View File

@ -29,6 +29,8 @@ en:
unfollow: Unfollow unfollow: Unfollow
application_mailer: application_mailer:
signature: Mastodon notifications from %{instance} signature: Mastodon notifications from %{instance}
settings: 'Change e-mail preferences: %{link}'
view: 'View:'
applications: applications:
invalid_url: The provided URL is invalid invalid_url: The provided URL is invalid
auth: auth:
@ -83,6 +85,15 @@ en:
reblog: reblog:
body: 'Your status was boosted by %{name}:' body: 'Your status was boosted by %{name}:'
subject: "%{name} boosted your status" subject: "%{name} boosted your status"
digest:
subject:
one: "1 new notification since your last visit 🐘"
other: "%{count} new notifications since your last visit 🐘"
body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
mention: "%{name} mentioned you in:"
new_followers_summary:
one: You have acquired one new follower! Yay!
other: You have gotten %{count} new followers! Amazing!
pagination: pagination:
next: Next next: Next
prev: Prev prev: Prev

View File

@ -34,6 +34,7 @@ en:
follow_request: Send e-mail when someone requests to follow you follow_request: Send e-mail when someone requests to follow you
mention: Send e-mail when someone mentions you mention: Send e-mail when someone mentions you
reblog: Send e-mail when someone reblogs your status reblog: Send e-mail when someone reblogs your status
digest: Send digest e-mails
'no': 'No' 'no': 'No'
required: required:
mark: "*" mark: "*"

View File

@ -127,6 +127,7 @@ Rails.application.routes.draw do
resources :media, only: [:create] resources :media, only: [:create]
resources :apps, only: [:create] resources :apps, only: [:create]
resources :blocks, only: [:index] resources :blocks, only: [:index]
resources :mutes, only: [:index]
resources :favourites, only: [:index] resources :favourites, only: [:index]
resources :reports, only: [:index, :create] resources :reports, only: [:index, :create]
resources :site, only: [:index] resources :site, only: [:index]
@ -153,7 +154,6 @@ Rails.application.routes.draw do
member do member do
get :statuses get :statuses
get 'statuses/media', to: 'accounts#media_statuses', as: :media_statuses
get :followers get :followers
get :following get :following
@ -161,6 +161,8 @@ Rails.application.routes.draw do
post :unfollow post :unfollow
post :block post :block
post :unblock post :unblock
post :mute
post :unmute
end end
end end
end end
@ -178,5 +180,8 @@ Rails.application.routes.draw do
root 'home#index' root 'home#index'
get '/:username', to: redirect('/users/%{username}')
get '/:username/:id', to: redirect('/users/%{username}/updates/%{id}')
match '*unmatched_route', via: :all, to: 'application#raise_not_found' match '*unmatched_route', via: :all, to: 'application#raise_not_found'
end end

View File

@ -11,6 +11,7 @@ defaults: &defaults
favourite: false favourite: false
mention: false mention: false
follow_request: true follow_request: true
digest: true
interactions: interactions:
must_be_follower: false must_be_follower: false
must_be_following: false must_be_following: false

View File

@ -0,0 +1,12 @@
class CreateMutes < ActiveRecord::Migration[5.0]
def change
create_table :mutes do |t|
t.integer :account_id, null: false
t.integer :target_account_id, null: false
t.timestamps null: false
end
add_index :mutes, [:account_id, :target_account_id], unique: true
end
end

View File

@ -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

View File

@ -0,0 +1,12 @@
class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
def up
add_column :media_attachments, :type, :integer, default: 0, null: false
MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
end
def down
remove_column :media_attachments, :type
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170217012631) do ActiveRecord::Schema.define(version: 20170304202101) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "shortcode" t.string "shortcode"
t.integer "type", default: 0, null: false
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
end end
@ -110,6 +111,14 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
end end
create_table "mutes", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "target_account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true, using: :btree
end
create_table "notifications", force: :cascade do |t| create_table "notifications", force: :cascade do |t|
t.integer "account_id" t.integer "account_id"
t.integer "activity_id" t.integer "activity_id"
@ -275,6 +284,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
t.string "encrypted_otp_secret_salt" t.string "encrypted_otp_secret_salt"
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login" t.boolean "otp_required_for_login"
t.datetime "last_emailed_at"
t.index ["account_id"], name: "index_users_on_account_id", using: :btree t.index ["account_id"], name: "index_users_on_account_id", using: :btree
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
t.index ["email"], name: "index_users_on_email", unique: true, using: :btree t.index ["email"], name: "index_users_on_email", unique: true, using: :btree

View File

@ -6,23 +6,16 @@ These people make the development of Mastodon possible through [Patreon](https:/
**Extra special Patrons** **Extra special Patrons**
- [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist) - [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
- [glocal](https://mastodon.social/users/glocal)
- [Jimmy Tidey](https://mastodon.social/users/jimmytidey) - [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
- [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene) - [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
- [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave) - [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
- [Zeiphner](https://mastodon.social/users/Zeipher) - [Zeipher](https://mastodon.social/users/Zeipher)
- [Effy Elden](https://toot.zone/users/effy) - [Effy Elden](https://toot.zone/users/effy)
- [Zoë Quinn](https://mastodon.social/users/zoequinn) - [Zoë Quinn](https://mastodon.social/users/zoequinn)
**Thank you to the following people** **Thank you to the following people**
- [Sophia Park](https://mastodon.social/users/sophia)
- [WelshPixie](https://mastodon.social/users/WelshPixie)
- [John Parker](https://mastodon.social/users/Middaparka)
- [Christina Hendricks](https://mastodon.social/users/clhendricksbc)
- [Jelle](http://jelv.nl)
- [Harris Bomberguy](https://mastodon.social/users/Hbomberguy) - [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
- [Martin Tithonium](https://mastodon.social/users/tithonium)
- [Edward Saperia](https://nwspk.com) - [Edward Saperia](https://nwspk.com)
- [Yoz Grahame](http://yoz.com/) - [Yoz Grahame](http://yoz.com/)
- [Jenn Kaplan](https://gay.crime.team/users/jkap) - [Jenn Kaplan](https://gay.crime.team/users/jkap)
@ -33,5 +26,21 @@ These people make the development of Mastodon possible through [Patreon](https:/
- [Niels Roesen Abildgaard](http://hypesystem.dk/) - [Niels Roesen Abildgaard](http://hypesystem.dk/)
- [Zatnosk](https://github.com/Zatnosk) - [Zatnosk](https://github.com/Zatnosk)
- [Spex Bluefox](https://mastodon.social/users/Spex) - [Spex Bluefox](https://mastodon.social/users/Spex)
- [Sam Waldie](https://mastodon.social/users/denjin)
- [J. C. Holder](http://jcholder.com/) - [J. C. Holder](http://jcholder.com/)
- [glocal](https://mastodon.social/users/glocal)
- [jk](https://mastodon.social/users/jk)
- [C418](https://mastodon.social/users/C418)
- [halcy](https://icosahedron.website/users/halcy)
- [Extropic](https://gnusocial.no/extropic)
- [Pat Monaghan](http://iwrite.software/)
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD
- TBD

View File

@ -5,11 +5,13 @@ Some people have started working on apps for the Mastodon API. Here is a list of
|App|Platform|Link|Developer(s)| |App|Platform|Link|Developer(s)|
|---|--------|----|------------| |---|--------|----|------------|
|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
|Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)| |Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)| |mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>|[@charlag@mastodon.social](https://mastodon.social/users/charlag)|
|tootstream|command-line|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>||
|Tooter|Chrome extension|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
|TootyFruity|Android|<https://play.google.com/store/apps/details?id=ch.kevinegli.tootyfruity221258>|[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)| |TootyFruity|Android|<https://play.google.com/store/apps/details?id=ch.kevinegli.tootyfruity221258>|[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)|
|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
|Amarok|iOS|<https://itunes.apple.com/us/app/amarok-for-mastodon/id1214116200?ls=1&mt=8>|[@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy)|
|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
|Tooter|Chrome|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
|tootstream|CLI|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
If you have a project like this, let me know so I can add it to the list! If you have a project like this, let me know so I can add it to the list!

View File

@ -11,8 +11,9 @@ List of Known Mastodon instances
| [epiktistes.com](https://epiktistes.com) |N/A|Yes| | [epiktistes.com](https://epiktistes.com) |N/A|Yes|
| [on.vu](https://on.vu) | Appears defunct|No| | [on.vu](https://on.vu) | Appears defunct|No|
| [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)| | [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
| [gnusocial.me](https://gnusocial.me) |Yes, it's a mastodon instance now|Yes|
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes| | [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
| [memetastic.space](https://memetastic.space) |Memes|Yes| | [memetastic.space](https://memetastic.space) |Memes|Yes|
| [social.diskseven.com](https://social.diskseven.com) |Single user|No|
| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|
Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request). Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).

View File

@ -76,6 +76,10 @@ Query parameters:
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time) - `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates) - `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
Query parameters for public and tag timelines only:
- `local` (optional): Only return statuses originating from this instance
### Notifications ### Notifications
**GET /api/v1/notifications** **GET /api/v1/notifications**
@ -116,7 +120,14 @@ Returns authenticated user's account.
**GET /api/v1/accounts/:id/statuses** **GET /api/v1/accounts/:id/statuses**
Returns statuses by user. Same options as timeline are permitted. Returns statuses by user.
Query parameters:
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
- `only_media` (optional): Only return statuses that have media attachments
- `exclude_replies` (optional): Skip statuses that reply to other statuses
**GET /api/v1/accounts/:id/following** **GET /api/v1/accounts/:id/following**
@ -128,7 +139,7 @@ Returns users the given user is followed by.
**GET /api/v1/accounts/relationships** **GET /api/v1/accounts/relationships**
Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts. Returns relationships (`following`, `followed_by`, `blocking`, `muting`, `requested`) of the current user to a list of given accounts.
Query parameters: Query parameters:
@ -147,6 +158,14 @@ Query parameters:
Returns accounts blocked by authenticated user. Returns accounts blocked by authenticated user.
**GET /api/v1/mutes**
Returns accounts muted by authenticated user.
**GET /api/v1/follow_requests**
Returns accounts that want to follow the authenticated user but are waiting for approval.
**GET /api/v1/favourites** **GET /api/v1/favourites**
Returns statuses favourited by authenticated user. Returns statuses favourited by authenticated user.
@ -215,6 +234,13 @@ Returns the updated relationship to the user.
Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site. Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site.
Does not require authentication. Does not require authentication.
# Muting and unmuting users
**POST /api/v1/accounts/:id/mute**
**POST /api/v1/accounts/:id/unmute**
Returns the updated relationship to the user.
### OAuth apps ### OAuth apps
**POST /api/v1/apps** **POST /api/v1/apps**

View File

@ -1,4 +1,4 @@
Push notifications Push notifications
================== ==================
**Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration** See <https://github.com/Gargron/tusky-api> for an example of how to create push notifications for a mobile app. It involves using the Mastodon streaming API on behalf of the app's users, as a sort of proxy.

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Paperclip
# This transcoder is only to be used for the MediaAttachment model
# to convert animated gifs to webm
class GifTranscoder < Paperclip::Processor
def make
num_frames = identify('-format %n :file', file: file.path).to_i
return file unless options[:style] == :original && num_frames > 1
final_file = Paperclip::Transcoder.make(file, options, attachment)
attachment.instance.file_file_name = 'media.mp4'
attachment.instance.file_content_type = 'video/mp4'
attachment.instance.type = MediaAttachment.types[:gifv]
final_file
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Paperclip
# This transcoder is only to be used for the MediaAttachment model
# to check when uploaded videos are actually gifv's
class VideoTranscoder < Paperclip::Processor
def make
meta = ::Av.cli.identify(@file.path)
attachment.instance.type = MediaAttachment.types[:gifv] unless meta[:audio_encode]
Paperclip::Transcoder.make(file, options, attachment)
end
end
end

View File

@ -43,7 +43,7 @@ namespace :mastodon do
namespace :feeds do namespace :feeds do
desc 'Clear timelines of inactive users' desc 'Clear timelines of inactive users'
task clear: :environment do task clear: :environment do
User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user| User.confirmed.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
Redis.current.del(FeedManager.instance.key(:home, user.account_id)) Redis.current.del(FeedManager.instance.key(:home, user.account_id))
end end
end end
@ -53,4 +53,13 @@ namespace :mastodon do
Redis.current.keys('feed:*').each { |key| Redis.current.del(key) } Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
end end
end end
namespace :emails do
desc 'Send out digest e-mails'
task digest: :environment do
User.confirmed.joins(:account).where(accounts: { silenced: false, suspended: false }).where('current_sign_in_at < ?', 20.days.ago).find_each do |user|
DigestMailerWorker.perform_async(user.id)
end
end
end
end end

View File

@ -24,6 +24,7 @@
"css-loader": "^0.26.2", "css-loader": "^0.26.2",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emojione": "latest", "emojione": "latest",
"emojione-picker": "^2.0.1",
"enzyme": "^2.7.1", "enzyme": "^2.7.1",
"es6-promise": "^3.2.1", "es6-promise": "^3.2.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@ -40,6 +41,7 @@
"react": "^15.4.2", "react": "^15.4.2",
"react-addons-perf": "^15.4.2", "react-addons-perf": "^15.4.2",
"react-addons-pure-render-mixin": "^15.4.2", "react-addons-pure-render-mixin": "^15.4.2",
"react-addons-shallow-compare": "^15.4.2",
"react-addons-test-utils": "^15.4.2", "react-addons-test-utils": "^15.4.2",
"react-autosuggest": "^7.0.1", "react-autosuggest": "^7.0.1",
"react-decoration": "^1.4.0", "react-decoration": "^1.4.0",
@ -60,7 +62,6 @@
"redis": "^2.6.5", "redis": "^2.6.5",
"redux": "^3.6.0", "redux": "^3.6.0",
"redux-immutable": "^3.1.0", "redux-immutable": "^3.1.0",
"redux-sounds": "^1.1.1",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"reselect": "^2.5.4", "reselect": "^2.5.4",
"sass-loader": "^6.0.2", "sass-loader": "^6.0.2",

View File

@ -116,6 +116,44 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
end end
end end
describe 'POST #mute' do
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
before do
user.account.follow!(other_account)
post :mute, params: {id: other_account.id }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'does not remove the following relation between user and target user' do
expect(user.account.following?(other_account)).to be true
end
it 'creates a muting relation' do
expect(user.account.muting?(other_account)).to be true
end
end
describe 'POST #unmute' do
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
before do
user.account.mute!(other_account)
post :unmute, params: { id: other_account.id }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'removes the muting relation between user and target user' do
expect(user.account.muting?(other_account)).to be false
end
end
describe 'GET #relationships' do describe 'GET #relationships' do
let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account } let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account }
let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account } let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account }

View File

@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Api::V1::MutesController, type: :controller do
render_views
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
let(:token) { double acceptable?: true, resource_owner_id: user.id }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
it 'returns http success' do
get :index
expect(response).to have_http_status(:success)
end
end
end

View File

@ -0,0 +1,3 @@
Fabricator(:mute) do
end

Some files were not shown because too many files have changed in this diff Show More