mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-12-01 11:19:04 +01:00
Merge pull request #525 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
6a1544bd46
37 changed files with 553 additions and 93 deletions
|
@ -88,6 +88,10 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
# CDN_HOST=https://assets.example.com
|
# CDN_HOST=https://assets.example.com
|
||||||
|
|
||||||
# S3 (optional)
|
# S3 (optional)
|
||||||
|
# The attachment host must allow cross origin request from WEB_DOMAIN or
|
||||||
|
# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
|
||||||
|
# following header field:
|
||||||
|
# Access-Control-Allow-Origin: https://192.168.1.123:9000/
|
||||||
# S3_ENABLED=true
|
# S3_ENABLED=true
|
||||||
# S3_BUCKET=
|
# S3_BUCKET=
|
||||||
# AWS_ACCESS_KEY_ID=
|
# AWS_ACCESS_KEY_ID=
|
||||||
|
@ -97,6 +101,8 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
# S3_HOSTNAME=192.168.1.123:9000
|
# S3_HOSTNAME=192.168.1.123:9000
|
||||||
|
|
||||||
# S3 (Minio Config (optional) Please check Minio instance for details)
|
# S3 (Minio Config (optional) Please check Minio instance for details)
|
||||||
|
# The attachment host must allow cross origin request - see the description
|
||||||
|
# above.
|
||||||
# S3_ENABLED=true
|
# S3_ENABLED=true
|
||||||
# S3_BUCKET=
|
# S3_BUCKET=
|
||||||
# AWS_ACCESS_KEY_ID=
|
# AWS_ACCESS_KEY_ID=
|
||||||
|
@ -108,6 +114,8 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
# S3_SIGNATURE_VERSION=
|
# S3_SIGNATURE_VERSION=
|
||||||
|
|
||||||
# Swift (optional)
|
# Swift (optional)
|
||||||
|
# The attachment host must allow cross origin request - see the description
|
||||||
|
# above.
|
||||||
# SWIFT_ENABLED=true
|
# SWIFT_ENABLED=true
|
||||||
# SWIFT_USERNAME=
|
# SWIFT_USERNAME=
|
||||||
# For Keystone V3, the value for SWIFT_TENANT should be the project name
|
# For Keystone V3, the value for SWIFT_TENANT should be the project name
|
||||||
|
|
|
@ -7,6 +7,9 @@ env:
|
||||||
es6: true
|
es6: true
|
||||||
jest: true
|
jest: true
|
||||||
|
|
||||||
|
globals:
|
||||||
|
ATTACHMENT_HOST: false
|
||||||
|
|
||||||
parser: babel-eslint
|
parser: babel-eslint
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
|
|
|
@ -23,15 +23,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def direct_statuses
|
def direct_statuses
|
||||||
direct_timeline_statuses.paginate_by_max_id(
|
direct_timeline_statuses
|
||||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
|
||||||
params[:max_id],
|
|
||||||
params[:since_id]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def direct_timeline_statuses
|
def direct_timeline_statuses
|
||||||
Status.as_direct_timeline(current_account)
|
# this query requires built in pagination.
|
||||||
|
Status.as_direct_timeline(
|
||||||
|
current_account,
|
||||||
|
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||||
|
params[:max_id],
|
||||||
|
params[:since_id],
|
||||||
|
true # returns array of cache_ids object
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_pagination_headers
|
def insert_pagination_headers
|
||||||
|
|
17
app/controllers/api/v1/trends_controller.rb
Normal file
17
app/controllers/api/v1/trends_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::TrendsController < Api::BaseController
|
||||||
|
before_action :set_tags
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @tags, each_serializer: REST::TagSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_tags
|
||||||
|
@tags = TrendingTags.get(limit_param(10))
|
||||||
|
end
|
||||||
|
end
|
8
app/controllers/api/v2/search_controller.rb
Normal file
8
app/controllers/api/v2/search_controller.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V2::SearchController < Api::V1::SearchController
|
||||||
|
def index
|
||||||
|
@search = Search.new(search)
|
||||||
|
render json: @search, serializer: REST::V2::SearchSerializer
|
||||||
|
end
|
||||||
|
end
|
|
@ -33,7 +33,7 @@ export function submitSearch() {
|
||||||
|
|
||||||
dispatch(fetchSearchRequest());
|
dispatch(fetchSearchRequest());
|
||||||
|
|
||||||
api(getState).get('/api/v1/search', {
|
api(getState).get('/api/v2/search', {
|
||||||
params: {
|
params: {
|
||||||
q: value,
|
q: value,
|
||||||
resolve: true,
|
resolve: true,
|
||||||
|
|
32
app/javascript/mastodon/actions/trends.js
Normal file
32
app/javascript/mastodon/actions/trends.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
|
||||||
|
export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
|
||||||
|
export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const fetchTrends = () => (dispatch, getState) => {
|
||||||
|
dispatch(fetchTrendsRequest());
|
||||||
|
|
||||||
|
api(getState)
|
||||||
|
.get('/api/v1/trends')
|
||||||
|
.then(({ data }) => dispatch(fetchTrendsSuccess(data)))
|
||||||
|
.catch(err => dispatch(fetchTrendsFail(err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTrendsRequest = () => ({
|
||||||
|
type: TRENDS_FETCH_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendsSuccess = trends => ({
|
||||||
|
type: TRENDS_FETCH_SUCCESS,
|
||||||
|
trends,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendsFail = error => ({
|
||||||
|
type: TRENDS_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
skipLoading: true,
|
||||||
|
skipAlert: true,
|
||||||
|
});
|
|
@ -25,6 +25,7 @@ export default class ScrollableList extends PureComponent {
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
prepend: PropTypes.node,
|
prepend: PropTypes.node,
|
||||||
|
alwaysPrepend: PropTypes.bool,
|
||||||
emptyMessage: PropTypes.node,
|
emptyMessage: PropTypes.node,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
@ -140,7 +141,7 @@ export default class ScrollableList extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
|
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
const childrenCount = React.Children.count(children);
|
const childrenCount = React.Children.count(children);
|
||||||
|
|
||||||
|
@ -172,9 +173,13 @@ export default class ScrollableList extends PureComponent {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
scrollableArea = (
|
scrollableArea = (
|
||||||
|
<div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{alwaysPrepend && prepend}
|
||||||
|
|
||||||
<div className='empty-column-indicator' ref={this.setRef}>
|
<div className='empty-column-indicator' ref={this.setRef}>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
prepend: PropTypes.node,
|
prepend: PropTypes.node,
|
||||||
emptyMessage: PropTypes.node,
|
emptyMessage: PropTypes.node,
|
||||||
|
alwaysPrepend: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
|
|
@ -127,6 +127,7 @@ export default class CommunityTimeline extends React.PureComponent {
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
prepend={headline}
|
prepend={headline}
|
||||||
|
alwaysPrepend
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`community_timeline-${columnId}`}
|
scrollKey={`community_timeline-${columnId}`}
|
||||||
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
||||||
|
|
|
@ -1,28 +1,82 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
import AccountContainer from '../../../containers/account_container';
|
import AccountContainer from '../../../containers/account_container';
|
||||||
import StatusContainer from '../../../containers/status_container';
|
import StatusContainer from '../../../containers/status_container';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
|
|
||||||
|
const shortNumberFormat = number => {
|
||||||
|
if (number < 1000) {
|
||||||
|
return <FormattedNumber value={number} />;
|
||||||
|
} else {
|
||||||
|
return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHashtag = hashtag => (
|
||||||
|
<div className='trends__item' key={hashtag.get('name')}>
|
||||||
|
<div className='trends__item__name'>
|
||||||
|
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
|
||||||
|
#<span>{hashtag.get('name')}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='trends__item__current'>
|
||||||
|
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='trends__item__sparkline'>
|
||||||
|
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
||||||
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
|
</Sparklines>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default class SearchResults extends ImmutablePureComponent {
|
export default class SearchResults extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
results: ImmutablePropTypes.map.isRequired,
|
results: ImmutablePropTypes.map.isRequired,
|
||||||
|
trends: ImmutablePropTypes.list,
|
||||||
|
fetchTrends: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { fetchTrends } = this.props;
|
||||||
|
fetchTrends();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { results } = this.props;
|
const { results, trends } = this.props;
|
||||||
|
|
||||||
let accounts, statuses, hashtags;
|
let accounts, statuses, hashtags;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
return (
|
||||||
|
<div className='search-results'>
|
||||||
|
<div className='trends'>
|
||||||
|
<div className='trends__header'>
|
||||||
|
<i className='fa fa-fire fa-fw' />
|
||||||
|
<FormattedMessage id='trends.header' defaultMessage='Trending now' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trends && trends.map(hashtag => renderHashtag(hashtag))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (results.get('accounts') && results.get('accounts').size > 0) {
|
if (results.get('accounts') && results.get('accounts').size > 0) {
|
||||||
count += results.get('accounts').size;
|
count += results.get('accounts').size;
|
||||||
accounts = (
|
accounts = (
|
||||||
<div className='search-results__section'>
|
<div className='search-results__section'>
|
||||||
<h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
<h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
||||||
|
|
||||||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent {
|
||||||
count += results.get('statuses').size;
|
count += results.get('statuses').size;
|
||||||
statuses = (
|
statuses = (
|
||||||
<div className='search-results__section'>
|
<div className='search-results__section'>
|
||||||
<h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
<h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
||||||
|
|
||||||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent {
|
||||||
count += results.get('hashtags').size;
|
count += results.get('hashtags').size;
|
||||||
hashtags = (
|
hashtags = (
|
||||||
<div className='search-results__section'>
|
<div className='search-results__section'>
|
||||||
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
<h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
||||||
|
|
||||||
{results.get('hashtags').map(hashtag => (
|
{results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
|
||||||
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
|
|
||||||
#{hashtag}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -58,6 +108,7 @@ export default class SearchResults extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div className='search-results'>
|
<div className='search-results'>
|
||||||
<div className='search-results__header'>
|
<div className='search-results__header'>
|
||||||
|
<i className='fa fa-search fa-fw' />
|
||||||
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
|
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import SearchResults from '../components/search_results';
|
import SearchResults from '../components/search_results';
|
||||||
|
import { fetchTrends } from '../../../actions/trends';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
results: state.getIn(['search', 'results']),
|
results: state.getIn(['search', 'results']),
|
||||||
|
trends: state.get('trends'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(SearchResults);
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
fetchTrends: () => dispatch(fetchTrends()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
|
||||||
|
|
|
@ -101,7 +101,7 @@ export default class Compose extends React.PureComponent {
|
||||||
{(multiColumn || isSearchPage) && <SearchContainer /> }
|
{(multiColumn || isSearchPage) && <SearchContainer /> }
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
<div className='drawer__pager'>
|
||||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
{!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
|
||||||
<NavigationContainer onClose={this.onBlur} />
|
<NavigationContainer onClose={this.onBlur} />
|
||||||
<ComposeFormContainer />
|
<ComposeFormContainer />
|
||||||
{multiColumn && (
|
{multiColumn && (
|
||||||
|
@ -109,7 +109,7 @@ export default class Compose extends React.PureComponent {
|
||||||
<img alt='' draggable='false' src={elephantUIPlane} />
|
<img alt='' draggable='false' src={elephantUIPlane} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||||
{({ x }) => (
|
{({ x }) => (
|
||||||
|
|
|
@ -127,6 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
prepend={headline}
|
prepend={headline}
|
||||||
|
alwaysPrepend
|
||||||
timelineId={`public${onlyMedia ? ':media' : ''}`}
|
timelineId={`public${onlyMedia ? ':media' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
|
|
|
@ -62,31 +62,28 @@ const makeMapStateToProps = () => {
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||||
function addAncestor(id) {
|
let id = status.get('in_reply_to_id');
|
||||||
if (id) {
|
|
||||||
const inReplyTo = state.getIn(['contexts', 'inReplyTos', id]);
|
|
||||||
|
|
||||||
|
while (id) {
|
||||||
mutable.unshift(id);
|
mutable.unshift(id);
|
||||||
addAncestor(inReplyTo);
|
id = state.getIn(['contexts', 'inReplyTos', id]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
addAncestor(status.get('in_reply_to_id'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
descendantsIds = descendantsIds.withMutations(mutable => {
|
descendantsIds = descendantsIds.withMutations(mutable => {
|
||||||
function addDescendantOf(id) {
|
const ids = [status.get('id')];
|
||||||
|
|
||||||
|
while (ids.length > 0) {
|
||||||
|
let id = ids.shift();
|
||||||
const replies = state.getIn(['contexts', 'replies', id]);
|
const replies = state.getIn(['contexts', 'replies', id]);
|
||||||
|
|
||||||
if (replies) {
|
if (replies) {
|
||||||
replies.forEach(reply => {
|
replies.forEach(reply => {
|
||||||
mutable.push(reply);
|
mutable.push(reply);
|
||||||
addDescendantOf(reply);
|
ids.unshift(reply);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addDescendantOf(status.get('id'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import height_cache from './height_cache';
|
||||||
import custom_emojis from './custom_emojis';
|
import custom_emojis from './custom_emojis';
|
||||||
import lists from './lists';
|
import lists from './lists';
|
||||||
import listEditor from './list_editor';
|
import listEditor from './list_editor';
|
||||||
|
import trends from './trends';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
dropdown_menu,
|
dropdown_menu,
|
||||||
|
@ -55,6 +56,7 @@ const reducers = {
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
lists,
|
lists,
|
||||||
listEditor,
|
listEditor,
|
||||||
|
trends,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
value: '',
|
value: '',
|
||||||
|
@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
|
||||||
return state.set('results', ImmutableMap({
|
return state.set('results', ImmutableMap({
|
||||||
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
||||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||||
hashtags: ImmutableList(action.results.hashtags),
|
hashtags: fromJS(action.results.hashtags),
|
||||||
})).set('submitted', true);
|
})).set('submitted', true);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
13
app/javascript/mastodon/reducers/trends.js
Normal file
13
app/javascript/mastodon/reducers/trends.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { TRENDS_FETCH_SUCCESS } from '../actions/trends';
|
||||||
|
import { fromJS } from 'immutable';
|
||||||
|
|
||||||
|
const initialState = null;
|
||||||
|
|
||||||
|
export default function trendsReducer(state = initialState, action) {
|
||||||
|
switch(action.type) {
|
||||||
|
case TRENDS_FETCH_SUCCESS:
|
||||||
|
return fromJS(action.trends);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
|
@ -49,7 +49,7 @@ self.addEventListener('fetch', function(event) {
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}));
|
}));
|
||||||
} else if (storageFreeable && process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) {
|
} else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
|
||||||
event.respondWith(openSystemCache().then(cache => {
|
event.respondWith(openSystemCache().then(cache => {
|
||||||
return cache.match(event.request.url).then(cached => {
|
return cache.match(event.request.url).then(cached => {
|
||||||
if (cached === undefined) {
|
if (cached === undefined) {
|
||||||
|
|
|
@ -132,20 +132,54 @@
|
||||||
.boost-modal,
|
.boost-modal,
|
||||||
.confirmation-modal,
|
.confirmation-modal,
|
||||||
.mute-modal,
|
.mute-modal,
|
||||||
.report-modal {
|
.report-modal,
|
||||||
|
.embed-modal,
|
||||||
|
.error-modal,
|
||||||
|
.onboarding-modal {
|
||||||
background: $ui-base-color;
|
background: $ui-base-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boost-modal__action-bar,
|
.boost-modal__action-bar,
|
||||||
.confirmation-modal__action-bar,
|
.confirmation-modal__action-bar,
|
||||||
.mute-modal__action-bar {
|
.mute-modal__action-bar,
|
||||||
|
.onboarding-modal__paginator,
|
||||||
|
.error-modal__footer {
|
||||||
background: darken($ui-base-color, 6%);
|
background: darken($ui-base-color, 6%);
|
||||||
|
|
||||||
|
.onboarding-modal__nav,
|
||||||
|
.error-modal__nav {
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background-color: darken($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-case__case {
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-modal .embed-modal__container .embed-modal__html {
|
||||||
|
background: $white;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: darken($ui-base-color, 6%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-track {
|
.react-toggle-track {
|
||||||
background: $ui-secondary-color;
|
background: $ui-secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||||
|
background: darken($ui-secondary-color, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||||
|
background: lighten($ui-highlight-color, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
// Change the default color used for the text in an empty column or on the error column
|
// Change the default color used for the text in an empty column or on the error column
|
||||||
.empty-column-indicator,
|
.empty-column-indicator,
|
||||||
.error-column {
|
.error-column {
|
||||||
|
|
|
@ -3284,6 +3284,15 @@ a.status-card {
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__icon {
|
.search__icon {
|
||||||
|
&::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-focus-inner,
|
||||||
|
&:focus {
|
||||||
|
outline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.fa {
|
.fa {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
@ -3333,40 +3342,33 @@ a.status-card {
|
||||||
.search-results__header {
|
.search-results__header {
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
background: lighten($ui-base-color, 2%);
|
background: lighten($ui-base-color, 2%);
|
||||||
border-bottom: 1px solid darken($ui-base-color, 4%);
|
padding: 15px;
|
||||||
padding: 15px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results__section {
|
.search-results__section {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 5px;
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
position: relative;
|
background: darken($ui-base-color, 4%);
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
width: 100%;
|
|
||||||
height: 0;
|
|
||||||
border-top: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: inline-block;
|
|
||||||
background: $ui-base-color;
|
|
||||||
color: $darker-text-color;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
padding: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: $dark-text-color;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5209,3 +5211,81 @@ noscript {
|
||||||
background: $ui-base-color;
|
background: $ui-base-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trends {
|
||||||
|
&__header {
|
||||||
|
color: $dark-text-color;
|
||||||
|
background: lighten($ui-base-color, 2%);
|
||||||
|
border-bottom: 1px solid darken($ui-base-color, 4%);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
color: $dark-text-color;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $darker-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
span {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__current {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
color: $secondary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sparkline {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 50px;
|
||||||
|
|
||||||
|
path {
|
||||||
|
stroke: lighten($highlight-text-color, 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -78,9 +78,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
return if tag['name'].blank?
|
return if tag['name'].blank?
|
||||||
|
|
||||||
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
||||||
hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
|
hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
|
||||||
|
|
||||||
status.tags << hashtag unless status.tags.include?(hashtag)
|
return if status.tags.include?(hashtag)
|
||||||
|
|
||||||
|
status.tags << hashtag
|
||||||
|
TrendingTags.record_use!(hashtag, status.account, status.created_at)
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -354,7 +354,7 @@ class OStatus::AtomSerializer
|
||||||
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
|
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
|
||||||
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
|
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
|
||||||
|
|
||||||
status.mentions.order(:id).each do |mentioned|
|
status.mentions.sort_by(&:id).each do |mentioned|
|
||||||
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
|
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,7 @@ module Remotable
|
||||||
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
|
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
|
||||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||||
nil
|
nil
|
||||||
rescue Paperclip::Error => e
|
rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
|
||||||
Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
|
Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -195,12 +195,45 @@ class Status < ApplicationRecord
|
||||||
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
|
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
|
||||||
end
|
end
|
||||||
|
|
||||||
def as_direct_timeline(account)
|
def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
|
||||||
query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
|
# direct timeline is mix of direct message from_me and to_me.
|
||||||
.where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
|
# 2 querys are executed with pagination.
|
||||||
.where(visibility: [:direct])
|
# constant expression using arel_table is required for partial index
|
||||||
|
|
||||||
apply_timeline_filters(query, account, false)
|
# _from_me part does not require any timeline filters
|
||||||
|
query_from_me = where(account_id: account.id)
|
||||||
|
.where(Status.arel_table[:visibility].eq(3))
|
||||||
|
.limit(limit)
|
||||||
|
.order('statuses.id DESC')
|
||||||
|
|
||||||
|
# _to_me part requires mute and block filter.
|
||||||
|
# FIXME: may we check mutes.hide_notifications?
|
||||||
|
query_to_me = Status
|
||||||
|
.joins(:mentions)
|
||||||
|
.merge(Mention.where(account_id: account.id))
|
||||||
|
.where(Status.arel_table[:visibility].eq(3))
|
||||||
|
.limit(limit)
|
||||||
|
.order('mentions.status_id DESC')
|
||||||
|
.not_excluded_by_account(account)
|
||||||
|
|
||||||
|
if max_id.present?
|
||||||
|
query_from_me = query_from_me.where('statuses.id < ?', max_id)
|
||||||
|
query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if since_id.present?
|
||||||
|
query_from_me = query_from_me.where('statuses.id > ?', since_id)
|
||||||
|
query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if cache_ids
|
||||||
|
# returns array of cache_ids object that have id and updated_at
|
||||||
|
(query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
|
||||||
|
else
|
||||||
|
# returns ActiveRecord.Relation
|
||||||
|
items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
|
||||||
|
Status.where(id: items.map(&:id))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def as_public_timeline(account = nil, local_only = false)
|
def as_public_timeline(account = nil, local_only = false)
|
||||||
|
|
|
@ -21,6 +21,22 @@ class Tag < ApplicationRecord
|
||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def history
|
||||||
|
days = []
|
||||||
|
|
||||||
|
7.times do |i|
|
||||||
|
day = i.days.ago.beginning_of_day.to_i
|
||||||
|
|
||||||
|
days << {
|
||||||
|
day: day.to_s,
|
||||||
|
uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
|
||||||
|
accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
days
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def search_for(term, limit = 5)
|
def search_for(term, limit = 5)
|
||||||
pattern = sanitize_sql_like(term.strip) + '%'
|
pattern = sanitize_sql_like(term.strip) + '%'
|
||||||
|
|
61
app/models/trending_tags.rb
Normal file
61
app/models/trending_tags.rb
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TrendingTags
|
||||||
|
KEY = 'trending_tags'
|
||||||
|
HALF_LIFE = 1.day.to_i
|
||||||
|
MAX_ITEMS = 500
|
||||||
|
EXPIRE_HISTORY_AFTER = 7.days.seconds
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def record_use!(tag, account, at_time = Time.now.utc)
|
||||||
|
return if disallowed_hashtags.include?(tag.name) || account.silenced?
|
||||||
|
|
||||||
|
increment_vote!(tag.id, at_time)
|
||||||
|
increment_historical_use!(tag.id, at_time)
|
||||||
|
increment_unique_use!(tag.id, account.id, at_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(limit)
|
||||||
|
tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i)
|
||||||
|
tags = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
|
||||||
|
tag_ids.map { |tag_id| tags[tag_id] }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def increment_vote!(tag_id, at_time)
|
||||||
|
redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s)
|
||||||
|
redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def increment_historical_use!(tag_id, at_time)
|
||||||
|
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
|
||||||
|
redis.incrby(key, 1)
|
||||||
|
redis.expire(key, EXPIRE_HISTORY_AFTER)
|
||||||
|
end
|
||||||
|
|
||||||
|
def increment_unique_use!(tag_id, account_id, at_time)
|
||||||
|
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
|
||||||
|
redis.pfadd(key, account_id)
|
||||||
|
redis.expire(key, EXPIRE_HISTORY_AFTER)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The epoch needs to be 2.5 years in the future if the half-life is one day
|
||||||
|
# While dynamic, it will always be the same within one year
|
||||||
|
def epoch
|
||||||
|
@epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def disallowed_hashtags
|
||||||
|
return @disallowed_hashtags if defined?(@disallowed_hashtags)
|
||||||
|
|
||||||
|
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
|
||||||
|
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
|
||||||
|
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
Redis.current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
11
app/serializers/rest/tag_serializer.rb
Normal file
11
app/serializers/rest/tag_serializer.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::TagSerializer < ActiveModel::Serializer
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
attributes :name, :url, :history
|
||||||
|
|
||||||
|
def url
|
||||||
|
tag_url(object)
|
||||||
|
end
|
||||||
|
end
|
7
app/serializers/rest/v2/search_serializer.rb
Normal file
7
app/serializers/rest/v2/search_serializer.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::V2::SearchSerializer < ActiveModel::Serializer
|
||||||
|
has_many :accounts, serializer: REST::AccountSerializer
|
||||||
|
has_many :statuses, serializer: REST::StatusSerializer
|
||||||
|
has_many :hashtags, serializer: REST::TagSerializer
|
||||||
|
end
|
|
@ -4,8 +4,10 @@ class ProcessHashtagsService < BaseService
|
||||||
def call(status, tags = [])
|
def call(status, tags = [])
|
||||||
tags = Extractor.extract_hashtags(status.text) if status.local?
|
tags = Extractor.extract_hashtags(status.text) if status.local?
|
||||||
|
|
||||||
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
|
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
|
||||||
status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
|
tag = Tag.where(name: name).first_or_create(name: name)
|
||||||
|
status.tags << tag
|
||||||
|
TrendingTags.record_use!(tag, status.account, status.created_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ require 'resolv'
|
||||||
|
|
||||||
class EmailMxValidator < ActiveModel::Validator
|
class EmailMxValidator < ActiveModel::Validator
|
||||||
def validate(user)
|
def validate(user)
|
||||||
return if Rails.env.test?
|
return if Rails.env.test? || Rails.env.development?
|
||||||
user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
|
user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -271,6 +271,7 @@ Rails.application.routes.draw do
|
||||||
resources :favourites, only: [:index]
|
resources :favourites, only: [:index]
|
||||||
resources :bookmarks, only: [:index]
|
resources :bookmarks, only: [:index]
|
||||||
resources :reports, only: [:index, :create]
|
resources :reports, only: [:index, :create]
|
||||||
|
resources :trends, only: [:index]
|
||||||
|
|
||||||
namespace :apps do
|
namespace :apps do
|
||||||
get :verify_credentials, to: 'credentials#show'
|
get :verify_credentials, to: 'credentials#show'
|
||||||
|
@ -332,6 +333,10 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :v2 do
|
||||||
|
get '/search', to: 'search#index', as: :search
|
||||||
|
end
|
||||||
|
|
||||||
namespace :web do
|
namespace :web do
|
||||||
resource :settings, only: [:update]
|
resource :settings, only: [:update]
|
||||||
resource :embed, only: [:create]
|
resource :embed, only: [:create]
|
||||||
|
|
|
@ -6,8 +6,9 @@ const CompressionPlugin = require('compression-webpack-plugin');
|
||||||
const sharedConfig = require('./shared.js');
|
const sharedConfig = require('./shared.js');
|
||||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||||
const OfflinePlugin = require('offline-plugin');
|
const OfflinePlugin = require('offline-plugin');
|
||||||
const { env, publicPath } = require('./configuration.js');
|
const { publicPath } = require('./configuration.js');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { URL } = require('whatwg-url');
|
||||||
|
|
||||||
let compressionAlgorithm;
|
let compressionAlgorithm;
|
||||||
try {
|
try {
|
||||||
|
@ -19,6 +20,21 @@ try {
|
||||||
compressionAlgorithm = 'gzip';
|
compressionAlgorithm = 'gzip';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let attachmentHost;
|
||||||
|
|
||||||
|
if (process.env.S3_ENABLED === 'true') {
|
||||||
|
if (process.env.S3_CLOUDFRONT_HOST) {
|
||||||
|
attachmentHost = process.env.S3_CLOUDFRONT_HOST;
|
||||||
|
} else {
|
||||||
|
attachmentHost = process.env.S3_HOSTNAME || `s3-${process.env.S3_REGION || 'us-east-1'}.amazonaws.com`;
|
||||||
|
}
|
||||||
|
} else if (process.env.SWIFT_ENABLED === 'true') {
|
||||||
|
const { host } = new URL(process.env.SWIFT_OBJECT_URL);
|
||||||
|
attachmentHost = host;
|
||||||
|
} else {
|
||||||
|
attachmentHost = null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = merge(sharedConfig, {
|
module.exports = merge(sharedConfig, {
|
||||||
output: {
|
output: {
|
||||||
filename: '[name]-[chunkhash].js',
|
filename: '[name]-[chunkhash].js',
|
||||||
|
@ -90,7 +106,7 @@ module.exports = merge(sharedConfig, {
|
||||||
'**/*.woff',
|
'**/*.woff',
|
||||||
],
|
],
|
||||||
ServiceWorker: {
|
ServiceWorker: {
|
||||||
entry: `imports-loader?process.env=>${encodeURIComponent(JSON.stringify(env))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`,
|
entry: `imports-loader?ATTACHMENT_HOST=>${encodeURIComponent(JSON.stringify(attachmentHost))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`,
|
||||||
cacheName: 'mastodon',
|
cacheName: 'mastodon',
|
||||||
output: '../assets/sw.js',
|
output: '../assets/sw.js',
|
||||||
publicPath: '/sw.js',
|
publicPath: '/sw.js',
|
||||||
|
|
|
@ -98,6 +98,7 @@
|
||||||
"react-redux-loading-bar": "^2.9.3",
|
"react-redux-loading-bar": "^2.9.3",
|
||||||
"react-router-dom": "^4.1.1",
|
"react-router-dom": "^4.1.1",
|
||||||
"react-router-scroll-4": "^1.0.0-beta.1",
|
"react-router-scroll-4": "^1.0.0-beta.1",
|
||||||
|
"react-sparklines": "^1.7.0",
|
||||||
"react-swipeable-views": "^0.12.3",
|
"react-swipeable-views": "^0.12.3",
|
||||||
"react-textarea-autosize": "^5.2.1",
|
"react-textarea-autosize": "^5.2.1",
|
||||||
"react-toggle": "^4.0.1",
|
"react-toggle": "^4.0.1",
|
||||||
|
@ -121,7 +122,8 @@
|
||||||
"webpack-bundle-analyzer": "^2.9.1",
|
"webpack-bundle-analyzer": "^2.9.1",
|
||||||
"webpack-manifest-plugin": "^1.2.1",
|
"webpack-manifest-plugin": "^1.2.1",
|
||||||
"webpack-merge": "^4.1.1",
|
"webpack-merge": "^4.1.1",
|
||||||
"websocket.js": "^0.1.12"
|
"websocket.js": "^0.1.12",
|
||||||
|
"whatwg-url": "^6.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-eslint": "^8.2.1",
|
"babel-eslint": "^8.2.1",
|
||||||
|
|
17
spec/controllers/emojis_controller_spec.rb
Normal file
17
spec/controllers/emojis_controller_spec.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe EmojisController do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:emoji) { Fabricate(:custom_emoji) }
|
||||||
|
|
||||||
|
describe 'GET #show' do
|
||||||
|
subject(:responce) { get :show, params: { id: emoji.id, format: :json } }
|
||||||
|
subject(:body) { JSON.parse(response.body, symbolize_names: true) }
|
||||||
|
|
||||||
|
it 'returns the right response' do
|
||||||
|
expect(responce).to have_http_status 200
|
||||||
|
expect(body[:name]).to eq ':coolcat:'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -370,24 +370,25 @@ RSpec.describe Status, type: :model do
|
||||||
expect(@results).to_not include(@followed_public_status)
|
expect(@results).to_not include(@followed_public_status)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes direct statuses mentioning recipient from followed' do
|
|
||||||
Fabricate(:mention, account: account, status: @followed_direct_status)
|
|
||||||
expect(@results).to include(@followed_direct_status)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not include direct statuses not mentioning recipient from followed' do
|
it 'does not include direct statuses not mentioning recipient from followed' do
|
||||||
expect(@results).to_not include(@followed_direct_status)
|
expect(@results).to_not include(@followed_direct_status)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes direct statuses mentioning recipient from non-followed' do
|
|
||||||
Fabricate(:mention, account: account, status: @not_followed_direct_status)
|
|
||||||
expect(@results).to include(@not_followed_direct_status)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not include direct statuses not mentioning recipient from non-followed' do
|
it 'does not include direct statuses not mentioning recipient from non-followed' do
|
||||||
expect(@results).to_not include(@not_followed_direct_status)
|
expect(@results).to_not include(@not_followed_direct_status)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'includes direct statuses mentioning recipient from followed' do
|
||||||
|
Fabricate(:mention, account: account, status: @followed_direct_status)
|
||||||
|
results2 = Status.as_direct_timeline(account)
|
||||||
|
expect(results2).to include(@followed_direct_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes direct statuses mentioning recipient from non-followed' do
|
||||||
|
Fabricate(:mention, account: account, status: @not_followed_direct_status)
|
||||||
|
results2 = Status.as_direct_timeline(account)
|
||||||
|
expect(results2).to include(@not_followed_direct_status)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.as_public_timeline' do
|
describe '.as_public_timeline' do
|
||||||
|
|
26
yarn.lock
26
yarn.lock
|
@ -4391,6 +4391,10 @@ lodash.restparam@^3.0.0:
|
||||||
version "3.6.1"
|
version "3.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
|
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
|
||||||
|
|
||||||
|
lodash.sortby@^4.7.0:
|
||||||
|
version "4.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||||
|
|
||||||
lodash.tail@^4.1.1:
|
lodash.tail@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
|
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
|
||||||
|
@ -6146,6 +6150,12 @@ react-router@^4.2.0:
|
||||||
prop-types "^15.5.4"
|
prop-types "^15.5.4"
|
||||||
warning "^3.0.0"
|
warning "^3.0.0"
|
||||||
|
|
||||||
|
react-sparklines@^1.7.0:
|
||||||
|
version "1.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"
|
||||||
|
dependencies:
|
||||||
|
prop-types "^15.5.10"
|
||||||
|
|
||||||
react-swipeable-views-core@^0.12.11:
|
react-swipeable-views-core@^0.12.11:
|
||||||
version "0.12.11"
|
version "0.12.11"
|
||||||
resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.12.11.tgz#3cf2b4daffbb36f9d69bd19bf5b2d5370b6b2c1b"
|
resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.12.11.tgz#3cf2b4daffbb36f9d69bd19bf5b2d5370b6b2c1b"
|
||||||
|
@ -7273,6 +7283,12 @@ tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^1.4.1"
|
punycode "^1.4.1"
|
||||||
|
|
||||||
|
tr46@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
|
||||||
|
dependencies:
|
||||||
|
punycode "^2.1.0"
|
||||||
|
|
||||||
tr46@~0.0.3:
|
tr46@~0.0.3:
|
||||||
version "0.0.3"
|
version "0.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||||
|
@ -7510,7 +7526,7 @@ webidl-conversions@^3.0.0:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
||||||
|
|
||||||
webidl-conversions@^4.0.0:
|
webidl-conversions@^4.0.0, webidl-conversions@^4.0.2:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
|
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
|
||||||
|
|
||||||
|
@ -7653,6 +7669,14 @@ whatwg-url@^4.3.0:
|
||||||
tr46 "~0.0.3"
|
tr46 "~0.0.3"
|
||||||
webidl-conversions "^3.0.0"
|
webidl-conversions "^3.0.0"
|
||||||
|
|
||||||
|
whatwg-url@^6.4.1:
|
||||||
|
version "6.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.4.1.tgz#fdb94b440fd4ad836202c16e9737d511f012fd67"
|
||||||
|
dependencies:
|
||||||
|
lodash.sortby "^4.7.0"
|
||||||
|
tr46 "^1.0.1"
|
||||||
|
webidl-conversions "^4.0.2"
|
||||||
|
|
||||||
whet.extend@~0.9.9:
|
whet.extend@~0.9.9:
|
||||||
version "0.9.9"
|
version "0.9.9"
|
||||||
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
|
resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
|
||||||
|
|
Loading…
Reference in a new issue