commit
f48c7689d2
3
Gemfile
3
Gemfile
|
@ -65,6 +65,7 @@ gem 'nsa', '~> 0.2'
|
||||||
gem 'oj', '~> 3.8'
|
gem 'oj', '~> 3.8'
|
||||||
gem 'ostatus2', '~> 2.0'
|
gem 'ostatus2', '~> 2.0'
|
||||||
gem 'ox', '~> 2.11'
|
gem 'ox', '~> 2.11'
|
||||||
|
gem 'parslet'
|
||||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
||||||
gem 'pundit', '~> 2.0'
|
gem 'pundit', '~> 2.0'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
|
@ -92,7 +93,7 @@ gem 'tzinfo-data', '~> 1.2019'
|
||||||
gem 'webpacker', '~> 4.0'
|
gem 'webpacker', '~> 4.0'
|
||||||
gem 'webpush'
|
gem 'webpush'
|
||||||
|
|
||||||
gem 'json-ld', '~> 3.0'
|
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2'
|
||||||
gem 'json-ld-preloaded', '~> 3.0'
|
gem 'json-ld-preloaded', '~> 3.0'
|
||||||
gem 'rdf-normalize', '~> 0.3'
|
gem 'rdf-normalize', '~> 0.3'
|
||||||
|
|
||||||
|
|
25
Gemfile.lock
25
Gemfile.lock
|
@ -5,6 +5,19 @@ GIT
|
||||||
specs:
|
specs:
|
||||||
posix-spawn (0.3.13)
|
posix-spawn (0.3.13)
|
||||||
|
|
||||||
|
GIT
|
||||||
|
remote: https://github.com/ruby-rdf/json-ld.git
|
||||||
|
revision: 345b7a5733308af827e8491d284dbafa9128d7a2
|
||||||
|
ref: 345b7a5733308af827e8491d284dbafa9128d7a2
|
||||||
|
specs:
|
||||||
|
json-ld (3.0.2)
|
||||||
|
htmlentities (~> 4.3)
|
||||||
|
json-canonicalization (~> 0.1)
|
||||||
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
|
multi_json (~> 1.13)
|
||||||
|
rack (>= 1.6, < 3.0)
|
||||||
|
rdf (~> 3.0, >= 3.0.8)
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/tmm1/http_parser.rb
|
remote: https://github.com/tmm1/http_parser.rb
|
||||||
revision: 54b17ba8c7d8d20a16dfc65d1775241833219cf2
|
revision: 54b17ba8c7d8d20a16dfc65d1775241833219cf2
|
||||||
|
@ -299,10 +312,8 @@ GEM
|
||||||
jaro_winkler (1.5.3)
|
jaro_winkler (1.5.3)
|
||||||
jmespath (1.4.0)
|
jmespath (1.4.0)
|
||||||
json (2.2.0)
|
json (2.2.0)
|
||||||
json-ld (3.0.2)
|
json-canonicalization (0.1.0)
|
||||||
multi_json (~> 1.12)
|
json-ld-preloaded (3.0.3)
|
||||||
rdf (>= 2.2.8, < 4.0)
|
|
||||||
json-ld-preloaded (3.0.2)
|
|
||||||
json-ld (~> 3.0)
|
json-ld (~> 3.0)
|
||||||
multi_json (~> 1.12)
|
multi_json (~> 1.12)
|
||||||
rdf (~> 3.0)
|
rdf (~> 3.0)
|
||||||
|
@ -406,6 +417,7 @@ GEM
|
||||||
parallel
|
parallel
|
||||||
parser (2.6.3.0)
|
parser (2.6.3.0)
|
||||||
ast (~> 2.4.0)
|
ast (~> 2.4.0)
|
||||||
|
parslet (1.8.2)
|
||||||
pastel (0.7.2)
|
pastel (0.7.2)
|
||||||
equatable (~> 0.5.0)
|
equatable (~> 0.5.0)
|
||||||
tty-color (~> 0.4.0)
|
tty-color (~> 0.4.0)
|
||||||
|
@ -480,7 +492,7 @@ GEM
|
||||||
thor (>= 0.19.0, < 2.0)
|
thor (>= 0.19.0, < 2.0)
|
||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (12.3.2)
|
rake (12.3.2)
|
||||||
rdf (3.0.9)
|
rdf (3.0.12)
|
||||||
hamster (~> 3.0)
|
hamster (~> 3.0)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.3.3)
|
rdf-normalize (0.3.3)
|
||||||
|
@ -703,7 +715,7 @@ DEPENDENCIES
|
||||||
i18n-tasks (~> 0.9)
|
i18n-tasks (~> 0.9)
|
||||||
idn-ruby
|
idn-ruby
|
||||||
iso-639
|
iso-639
|
||||||
json-ld (~> 3.0)
|
json-ld!
|
||||||
json-ld-preloaded (~> 3.0)
|
json-ld-preloaded (~> 3.0)
|
||||||
kaminari (~> 1.1)
|
kaminari (~> 1.1)
|
||||||
letter_opener (~> 1.7)
|
letter_opener (~> 1.7)
|
||||||
|
@ -728,6 +740,7 @@ DEPENDENCIES
|
||||||
paperclip (~> 6.0)
|
paperclip (~> 6.0)
|
||||||
paperclip-av-transcoder (~> 0.6)
|
paperclip-av-transcoder (~> 0.6)
|
||||||
parallel_tests (~> 2.29)
|
parallel_tests (~> 2.29)
|
||||||
|
parslet
|
||||||
pg (~> 1.1)
|
pg (~> 1.1)
|
||||||
pghero (~> 2.2)
|
pghero (~> 2.2)
|
||||||
pkg-config (~> 1.3)
|
pkg-config (~> 1.3)
|
||||||
|
|
|
@ -44,7 +44,7 @@ class InvitesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def invites
|
def invites
|
||||||
Invite.where(user: current_user).order(id: :desc)
|
current_user.invites.order(id: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
|
|
|
@ -130,7 +130,7 @@ module JsonLdHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
doc = JSON::LD::API::RemoteDocument.new(url, json)
|
doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url)
|
||||||
|
|
||||||
block_given? ? yield(doc) : doc
|
block_given? ? yield(doc) : doc
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,6 +23,7 @@ export function blockDomain(domain) {
|
||||||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
||||||
const at_domain = '@' + domain;
|
const at_domain = '@' + domain;
|
||||||
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
||||||
|
|
||||||
dispatch(blockDomainSuccess(domain, accounts));
|
dispatch(blockDomainSuccess(domain, accounts));
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
dispatch(blockDomainFail(domain, err));
|
dispatch(blockDomainFail(domain, err));
|
||||||
|
|
|
@ -10,6 +10,10 @@ export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||||
|
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||||
|
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||||
|
|
||||||
export function changeSearch(value) {
|
export function changeSearch(value) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_CHANGE,
|
type: SEARCH_CHANGE,
|
||||||
|
@ -77,8 +81,50 @@ export function fetchSearchFail(error) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function showSearch() {
|
export const expandSearch = type => (dispatch, getState) => {
|
||||||
return {
|
const value = getState().getIn(['search', 'value']);
|
||||||
type: SEARCH_SHOW,
|
const offset = getState().getIn(['search', 'results', type]).size;
|
||||||
};
|
|
||||||
|
dispatch(expandSearchRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/search', {
|
||||||
|
params: {
|
||||||
|
q: value,
|
||||||
|
type,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
if (data.accounts) {
|
||||||
|
dispatch(importFetchedAccounts(data.accounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.statuses) {
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(expandSearchSuccess(data, value, type));
|
||||||
|
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(expandSearchFail(error));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const expandSearchRequest = () => ({
|
||||||
|
type: SEARCH_EXPAND_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
||||||
|
type: SEARCH_EXPAND_SUCCESS,
|
||||||
|
results,
|
||||||
|
searchTerm,
|
||||||
|
searchType,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandSearchFail = error => ({
|
||||||
|
type: SEARCH_EXPAND_FAIL,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const showSearch = () => ({
|
||||||
|
type: SEARCH_SHOW,
|
||||||
|
});
|
||||||
|
|
|
@ -51,6 +51,7 @@ export default class StatusContent extends React.PureComponent {
|
||||||
} else {
|
} else {
|
||||||
link.addEventListener('click', this.onLinkClick.bind(this), false);
|
link.addEventListener('click', this.onLinkClick.bind(this), false);
|
||||||
link.setAttribute('title', link.href);
|
link.setAttribute('title', link.href);
|
||||||
|
link.classList.add('unhandled-link');
|
||||||
}
|
}
|
||||||
|
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
|
|
|
@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||||
import Icon from 'flavours/glitch/components/icon';
|
import Icon from 'flavours/glitch/components/icon';
|
||||||
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
||||||
|
import LoadMore from 'flavours/glitch/components/load_more';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
||||||
|
@ -20,15 +21,24 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
results: ImmutablePropTypes.map.isRequired,
|
results: ImmutablePropTypes.map.isRequired,
|
||||||
suggestions: ImmutablePropTypes.list.isRequired,
|
suggestions: ImmutablePropTypes.list.isRequired,
|
||||||
fetchSuggestions: PropTypes.func.isRequired,
|
fetchSuggestions: PropTypes.func.isRequired,
|
||||||
|
expandSearch: PropTypes.func.isRequired,
|
||||||
dismissSuggestion: PropTypes.func.isRequired,
|
dismissSuggestion: PropTypes.func.isRequired,
|
||||||
searchTerm: PropTypes.string,
|
searchTerm: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.props.fetchSuggestions();
|
if (this.props.searchTerm === '') {
|
||||||
|
this.props.fetchSuggestions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
|
||||||
|
|
||||||
|
handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
|
||||||
|
|
||||||
|
handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
||||||
|
|
||||||
|
@ -75,6 +85,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
<h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
||||||
|
|
||||||
{results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
|
{results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
|
||||||
|
|
||||||
|
{results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -86,6 +98,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
<h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
||||||
|
|
||||||
{results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
|
{results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
|
||||||
|
|
||||||
|
{results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -97,6 +111,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
<h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
||||||
|
|
||||||
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||||
|
|
||||||
|
{results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import SearchResults from '../components/search_results';
|
import SearchResults from '../components/search_results';
|
||||||
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||||
|
import { expandSearch } from 'mastodon/actions/search';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
results: state.getIn(['search', 'results']),
|
results: state.getIn(['search', 'results']),
|
||||||
|
@ -10,6 +11,7 @@ const mapStateToProps = state => ({
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchSuggestions: () => dispatch(fetchSuggestions()),
|
fetchSuggestions: () => dispatch(fetchSuggestions()),
|
||||||
|
expandSearch: type => dispatch(expandSearch(type)),
|
||||||
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
|
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {
|
||||||
CONVERSATIONS_UPDATE,
|
CONVERSATIONS_UPDATE,
|
||||||
CONVERSATIONS_READ,
|
CONVERSATIONS_READ,
|
||||||
} from '../actions/conversations';
|
} from '../actions/conversations';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
|
||||||
import compareId from 'flavours/glitch/util/compare_id';
|
import compareId from 'flavours/glitch/util/compare_id';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -74,6 +76,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterConversations = (state, accountIds) => {
|
||||||
|
return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
|
||||||
|
};
|
||||||
|
|
||||||
export default function conversations(state = initialState, action) {
|
export default function conversations(state = initialState, action) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case CONVERSATIONS_FETCH_REQUEST:
|
case CONVERSATIONS_FETCH_REQUEST:
|
||||||
|
@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return filterConversations(state, [action.relationship.id]);
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterConversations(state, action.accounts);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
ACCOUNT_MUTE_SUCCESS,
|
ACCOUNT_MUTE_SUCCESS,
|
||||||
} from 'flavours/glitch/actions/accounts';
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
|
||||||
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
|
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import compareId from 'flavours/glitch/util/compare_id';
|
import compareId from 'flavours/glitch/util/compare_id';
|
||||||
|
@ -110,8 +111,8 @@ const expandNormalizedNotifications = (state, notifications, next, usePendingIte
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterNotifications = (state, relationship) => {
|
const filterNotifications = (state, accountIds) => {
|
||||||
const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id);
|
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')));
|
||||||
return state.update('items', helper).update('pendingItems', helper);
|
return state.update('items', helper).update('pendingItems', helper);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -217,9 +218,11 @@ export default function notifications(state = initialState, action) {
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
|
return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
return filterNotifications(state, action.relationship);
|
return filterNotifications(state, [action.relationship.id]);
|
||||||
case ACCOUNT_MUTE_SUCCESS:
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
|
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterNotifications(state, action.accounts);
|
||||||
case NOTIFICATIONS_CLEAR:
|
case NOTIFICATIONS_CLEAR:
|
||||||
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
|
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
SEARCH_CLEAR,
|
SEARCH_CLEAR,
|
||||||
SEARCH_FETCH_SUCCESS,
|
SEARCH_FETCH_SUCCESS,
|
||||||
SEARCH_SHOW,
|
SEARCH_SHOW,
|
||||||
|
SEARCH_EXPAND_SUCCESS,
|
||||||
} from 'flavours/glitch/actions/search';
|
} from 'flavours/glitch/actions/search';
|
||||||
import {
|
import {
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
|
@ -42,6 +43,8 @@ export default function search(state = initialState, action) {
|
||||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||||
hashtags: fromJS(action.results.hashtags),
|
hashtags: fromJS(action.results.hashtags),
|
||||||
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
||||||
|
case SEARCH_EXPAND_SUCCESS:
|
||||||
|
return state.updateIn(['results', action.searchType], list => list.concat(action.results[action.searchType].map(item => item.id)));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import {
|
||||||
SUGGESTIONS_FETCH_FAIL,
|
SUGGESTIONS_FETCH_FAIL,
|
||||||
SUGGESTIONS_DISMISS,
|
SUGGESTIONS_DISMISS,
|
||||||
} from '../actions/suggestions';
|
} from '../actions/suggestions';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
|
||||||
return state.set('isLoading', false);
|
return state.set('isLoading', false);
|
||||||
case SUGGESTIONS_DISMISS:
|
case SUGGESTIONS_DISMISS:
|
||||||
return state.update('items', list => list.filterNot(id => id === action.id));
|
return state.update('items', list => list.filterNot(id => id === action.id));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => id === action.relationship.id));
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,8 +79,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results__info {
|
.search-results__info {
|
||||||
padding: 10px;
|
padding: 20px;
|
||||||
color: $secondary-text-color;
|
color: $darker-text-color;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trends {
|
.trends {
|
||||||
|
|
|
@ -133,6 +133,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.unhandled-link {
|
||||||
|
color: lighten($ui-highlight-color, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
.status__content__spoiler-link {
|
||||||
background: lighten($ui-base-color, 30%);
|
background: lighten($ui-base-color, 30%);
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { showAlertForError } from './alerts';
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
let cancelFetchComposeSuggestionsAccounts;
|
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
|
||||||
|
|
||||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||||
|
@ -325,10 +325,12 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
|
||||||
if (cancelFetchComposeSuggestionsAccounts) {
|
if (cancelFetchComposeSuggestionsAccounts) {
|
||||||
cancelFetchComposeSuggestionsAccounts();
|
cancelFetchComposeSuggestionsAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
api(getState).get('/api/v1/accounts/search', {
|
api(getState).get('/api/v1/accounts/search', {
|
||||||
cancelToken: new CancelToken(cancel => {
|
cancelToken: new CancelToken(cancel => {
|
||||||
cancelFetchComposeSuggestionsAccounts = cancel;
|
cancelFetchComposeSuggestionsAccounts = cancel;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
q: token.slice(1),
|
q: token.slice(1),
|
||||||
resolve: false,
|
resolve: false,
|
||||||
|
@ -349,9 +351,30 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
|
||||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchComposeSuggestionsTags = (dispatch, getState, token) => {
|
const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
|
||||||
dispatch(updateSuggestionTags(token));
|
if (cancelFetchComposeSuggestionsTags) {
|
||||||
};
|
cancelFetchComposeSuggestionsTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/search', {
|
||||||
|
cancelToken: new CancelToken(cancel => {
|
||||||
|
cancelFetchComposeSuggestionsTags = cancel;
|
||||||
|
}),
|
||||||
|
|
||||||
|
params: {
|
||||||
|
type: 'hashtags',
|
||||||
|
q: token.slice(1),
|
||||||
|
resolve: false,
|
||||||
|
limit: 4,
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
dispatch(readyComposeSuggestionsTags(token, data.hashtags));
|
||||||
|
}).catch(error => {
|
||||||
|
if (!isCancel(error)) {
|
||||||
|
dispatch(showAlertForError(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200, { leading: true, trailing: true });
|
||||||
|
|
||||||
export function fetchComposeSuggestions(token) {
|
export function fetchComposeSuggestions(token) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
@ -385,6 +408,12 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readyComposeSuggestionsTags = (token, tags) => ({
|
||||||
|
type: COMPOSE_SUGGESTIONS_READY,
|
||||||
|
token,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
export function selectComposeSuggestion(position, token, suggestion, path) {
|
export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
let completion, startPosition;
|
let completion, startPosition;
|
||||||
|
@ -394,8 +423,8 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||||
startPosition = position - 1;
|
startPosition = position - 1;
|
||||||
|
|
||||||
dispatch(useEmoji(suggestion));
|
dispatch(useEmoji(suggestion));
|
||||||
} else if (suggestion[0] === '#') {
|
} else if (typeof suggestion === 'object' && suggestion.name) {
|
||||||
completion = suggestion;
|
completion = `#${suggestion.name}`;
|
||||||
startPosition = position - 1;
|
startPosition = position - 1;
|
||||||
} else {
|
} else {
|
||||||
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
||||||
|
|
|
@ -23,6 +23,7 @@ export function blockDomain(domain) {
|
||||||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
||||||
const at_domain = '@' + domain;
|
const at_domain = '@' + domain;
|
||||||
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
||||||
|
|
||||||
dispatch(blockDomainSuccess(domain, accounts));
|
dispatch(blockDomainSuccess(domain, accounts));
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
dispatch(blockDomainFail(domain, err));
|
dispatch(blockDomainFail(domain, err));
|
||||||
|
|
|
@ -10,6 +10,10 @@ export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||||
|
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||||
|
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||||
|
|
||||||
export function changeSearch(value) {
|
export function changeSearch(value) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_CHANGE,
|
type: SEARCH_CHANGE,
|
||||||
|
@ -77,8 +81,50 @@ export function fetchSearchFail(error) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function showSearch() {
|
export const expandSearch = type => (dispatch, getState) => {
|
||||||
return {
|
const value = getState().getIn(['search', 'value']);
|
||||||
type: SEARCH_SHOW,
|
const offset = getState().getIn(['search', 'results', type]).size;
|
||||||
};
|
|
||||||
|
dispatch(expandSearchRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/search', {
|
||||||
|
params: {
|
||||||
|
q: value,
|
||||||
|
type,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
if (data.accounts) {
|
||||||
|
dispatch(importFetchedAccounts(data.accounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.statuses) {
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(expandSearchSuccess(data, value, type));
|
||||||
|
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(expandSearchFail(error));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const expandSearchRequest = () => ({
|
||||||
|
type: SEARCH_EXPAND_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
||||||
|
type: SEARCH_EXPAND_SUCCESS,
|
||||||
|
results,
|
||||||
|
searchTerm,
|
||||||
|
searchType,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandSearchFail = error => ({
|
||||||
|
type: SEARCH_EXPAND_FAIL,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const showSearch = () => ({
|
||||||
|
type: SEARCH_SHOW,
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
export default class AutosuggestHashtag extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
tag: PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
url: PropTypes.string,
|
||||||
|
history: PropTypes.array.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { tag } = this.props;
|
||||||
|
const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='autosuggest-hashtag'>
|
||||||
|
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
||||||
|
<div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
|
import AutosuggestHashtag from './autosuggest_hashtag';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
import { isRtl } from '../rtl';
|
||||||
|
@ -167,12 +168,12 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
||||||
const { selectedSuggestion } = this.state;
|
const { selectedSuggestion } = this.state;
|
||||||
let inner, key;
|
let inner, key;
|
||||||
|
|
||||||
if (typeof suggestion === 'object') {
|
if (typeof suggestion === 'object' && suggestion.shortcode) {
|
||||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||||
key = suggestion.id;
|
key = suggestion.id;
|
||||||
} else if (suggestion[0] === '#') {
|
} else if (typeof suggestion === 'object' && suggestion.name) {
|
||||||
inner = suggestion;
|
inner = <AutosuggestHashtag tag={suggestion} />;
|
||||||
key = suggestion;
|
key = suggestion.name;
|
||||||
} else {
|
} else {
|
||||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||||
key = suggestion;
|
key = suggestion;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
|
import AutosuggestHashtag from './autosuggest_hashtag';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
import { isRtl } from '../rtl';
|
||||||
|
@ -173,12 +174,12 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
const { selectedSuggestion } = this.state;
|
const { selectedSuggestion } = this.state;
|
||||||
let inner, key;
|
let inner, key;
|
||||||
|
|
||||||
if (typeof suggestion === 'object') {
|
if (typeof suggestion === 'object' && suggestion.shortcode) {
|
||||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||||
key = suggestion.id;
|
key = suggestion.id;
|
||||||
} else if (suggestion[0] === '#') {
|
} else if (typeof suggestion === 'object' && suggestion.name) {
|
||||||
inner = suggestion;
|
inner = <AutosuggestHashtag tag={suggestion} />;
|
||||||
key = suggestion;
|
key = suggestion.name;
|
||||||
} else {
|
} else {
|
||||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||||
key = suggestion;
|
key = suggestion;
|
||||||
|
|
|
@ -55,6 +55,7 @@ export default class StatusContent extends React.PureComponent {
|
||||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||||
} else {
|
} else {
|
||||||
link.setAttribute('title', link.href);
|
link.setAttribute('title', link.href);
|
||||||
|
link.classList.add('unhandled-link');
|
||||||
}
|
}
|
||||||
|
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
|
@ -233,46 +234,23 @@ export default class StatusContent extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (this.props.onClick) {
|
} else if (this.props.onClick) {
|
||||||
const output = [
|
return (
|
||||||
<div
|
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||||
ref={this.setRef}
|
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||||
tabIndex='0'
|
|
||||||
key='content'
|
|
||||||
className={classNames}
|
|
||||||
style={directionStyle}
|
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
lang={status.get('language')}
|
|
||||||
onMouseDown={this.handleMouseDown}
|
|
||||||
onMouseUp={this.handleMouseUp}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (this.state.collapsed) {
|
{!!this.state.collapsed && readMoreButton}
|
||||||
output.push(readMoreButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.get('poll')) {
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
output.push(<PollContainer pollId={status.get('poll')} />);
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
return output;
|
|
||||||
} else {
|
} else {
|
||||||
const output = [
|
return (
|
||||||
<div
|
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
|
||||||
tabIndex='0'
|
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||||
ref={this.setRef}
|
|
||||||
className='status__content'
|
|
||||||
style={directionStyle}
|
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
lang={status.get('language')}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (status.get('poll')) {
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
output.push(<PollContainer pollId={status.get('poll')} />);
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Hashtag from '../../../components/hashtag';
|
import Hashtag from '../../../components/hashtag';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import { searchEnabled } from '../../../initial_state';
|
import { searchEnabled } from '../../../initial_state';
|
||||||
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
||||||
|
@ -20,15 +21,24 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
results: ImmutablePropTypes.map.isRequired,
|
results: ImmutablePropTypes.map.isRequired,
|
||||||
suggestions: ImmutablePropTypes.list.isRequired,
|
suggestions: ImmutablePropTypes.list.isRequired,
|
||||||
fetchSuggestions: PropTypes.func.isRequired,
|
fetchSuggestions: PropTypes.func.isRequired,
|
||||||
|
expandSearch: PropTypes.func.isRequired,
|
||||||
dismissSuggestion: PropTypes.func.isRequired,
|
dismissSuggestion: PropTypes.func.isRequired,
|
||||||
searchTerm: PropTypes.string,
|
searchTerm: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.props.fetchSuggestions();
|
if (this.props.searchTerm === '') {
|
||||||
|
this.props.fetchSuggestions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
|
||||||
|
|
||||||
|
handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
|
||||||
|
|
||||||
|
handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
||||||
|
|
||||||
|
@ -65,6 +75,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
<h5><Icon id='users' fixedWidth /><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} />)}
|
||||||
|
|
||||||
|
{results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -76,6 +88,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
<h5><Icon id='quote-right' fixedWidth /><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} />)}
|
||||||
|
|
||||||
|
{results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
||||||
|
@ -97,6 +111,8 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
<h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
<h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
||||||
|
|
||||||
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||||
|
|
||||||
|
{results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import SearchResults from '../components/search_results';
|
import SearchResults from '../components/search_results';
|
||||||
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||||
|
import { expandSearch } from 'mastodon/actions/search';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
results: state.getIn(['search', 'results']),
|
results: state.getIn(['search', 'results']),
|
||||||
|
@ -10,6 +11,7 @@ const mapStateToProps = state => ({
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchSuggestions: () => dispatch(fetchSuggestions()),
|
fetchSuggestions: () => dispatch(fetchSuggestions()),
|
||||||
|
expandSearch: type => dispatch(expandSearch(type)),
|
||||||
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
|
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {
|
||||||
COMPOSE_SUGGESTIONS_CLEAR,
|
COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
COMPOSE_SUGGESTIONS_READY,
|
COMPOSE_SUGGESTIONS_READY,
|
||||||
COMPOSE_SUGGESTION_SELECT,
|
COMPOSE_SUGGESTION_SELECT,
|
||||||
COMPOSE_SUGGESTION_TAGS_UPDATE,
|
|
||||||
COMPOSE_TAG_HISTORY_UPDATE,
|
COMPOSE_TAG_HISTORY_UPDATE,
|
||||||
COMPOSE_SENSITIVITY_CHANGE,
|
COMPOSE_SENSITIVITY_CHANGE,
|
||||||
COMPOSE_SPOILERNESS_CHANGE,
|
COMPOSE_SPOILERNESS_CHANGE,
|
||||||
|
@ -144,15 +143,20 @@ const insertSuggestion = (state, position, token, completion, path) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSuggestionTags = (state, token) => {
|
const sortHashtagsByUse = (state, tags) => {
|
||||||
const prefix = token.slice(1);
|
const personalHistory = state.get('tagHistory');
|
||||||
|
|
||||||
return state.merge({
|
return tags.sort((a, b) => {
|
||||||
suggestions: state.get('tagHistory')
|
const usedA = personalHistory.includes(a.name);
|
||||||
.filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase()))
|
const usedB = personalHistory.includes(b.name);
|
||||||
.slice(0, 4)
|
|
||||||
.map(tag => '#' + tag),
|
if (usedA === usedB) {
|
||||||
suggestion_token: token,
|
return 0;
|
||||||
|
} else if (usedA && !usedB) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -201,6 +205,16 @@ const expiresInFromExpiresAt = expires_at => {
|
||||||
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
|
||||||
|
if (accounts) {
|
||||||
|
return accounts.map(item => item.id);
|
||||||
|
} else if (emojis) {
|
||||||
|
return emojis;
|
||||||
|
} else {
|
||||||
|
return sortHashtagsByUse(state, tags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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:
|
||||||
|
@ -311,11 +325,9 @@ export default function compose(state = initialState, action) {
|
||||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
|
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
|
return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
|
||||||
case COMPOSE_SUGGESTION_SELECT:
|
case COMPOSE_SUGGESTION_SELECT:
|
||||||
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
|
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
|
||||||
case COMPOSE_SUGGESTION_TAGS_UPDATE:
|
|
||||||
return updateSuggestionTags(state, action.token);
|
|
||||||
case COMPOSE_TAG_HISTORY_UPDATE:
|
case COMPOSE_TAG_HISTORY_UPDATE:
|
||||||
return state.set('tagHistory', fromJS(action.tags));
|
return state.set('tagHistory', fromJS(action.tags));
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {
|
||||||
CONVERSATIONS_UPDATE,
|
CONVERSATIONS_UPDATE,
|
||||||
CONVERSATIONS_READ,
|
CONVERSATIONS_READ,
|
||||||
} from '../actions/conversations';
|
} from '../actions/conversations';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import compareId from '../compare_id';
|
import compareId from '../compare_id';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -74,6 +76,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterConversations = (state, accountIds) => {
|
||||||
|
return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
|
||||||
|
};
|
||||||
|
|
||||||
export default function conversations(state = initialState, action) {
|
export default function conversations(state = initialState, action) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case CONVERSATIONS_FETCH_REQUEST:
|
case CONVERSATIONS_FETCH_REQUEST:
|
||||||
|
@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return filterConversations(state, [action.relationship.id]);
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterConversations(state, action.accounts);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
ACCOUNT_MUTE_SUCCESS,
|
ACCOUNT_MUTE_SUCCESS,
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import compareId from '../compare_id';
|
import compareId from '../compare_id';
|
||||||
|
@ -83,8 +84,8 @@ const expandNormalizedNotifications = (state, notifications, next, usePendingIte
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterNotifications = (state, relationship) => {
|
const filterNotifications = (state, accountIds) => {
|
||||||
const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id);
|
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')));
|
||||||
return state.update('items', helper).update('pendingItems', helper);
|
return state.update('items', helper).update('pendingItems', helper);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -118,9 +119,11 @@ export default function notifications(state = initialState, action) {
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
|
return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
return filterNotifications(state, action.relationship);
|
return filterNotifications(state, [action.relationship.id]);
|
||||||
case ACCOUNT_MUTE_SUCCESS:
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
|
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterNotifications(state, action.accounts);
|
||||||
case NOTIFICATIONS_CLEAR:
|
case NOTIFICATIONS_CLEAR:
|
||||||
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
|
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
SEARCH_CLEAR,
|
SEARCH_CLEAR,
|
||||||
SEARCH_FETCH_SUCCESS,
|
SEARCH_FETCH_SUCCESS,
|
||||||
SEARCH_SHOW,
|
SEARCH_SHOW,
|
||||||
|
SEARCH_EXPAND_SUCCESS,
|
||||||
} from '../actions/search';
|
} from '../actions/search';
|
||||||
import {
|
import {
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
|
@ -42,6 +43,8 @@ export default function search(state = initialState, action) {
|
||||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||||
hashtags: fromJS(action.results.hashtags),
|
hashtags: fromJS(action.results.hashtags),
|
||||||
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
||||||
|
case SEARCH_EXPAND_SUCCESS:
|
||||||
|
return state.updateIn(['results', action.searchType], list => list.concat(action.results[action.searchType].map(item => item.id)));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import {
|
||||||
SUGGESTIONS_FETCH_FAIL,
|
SUGGESTIONS_FETCH_FAIL,
|
||||||
SUGGESTIONS_DISMISS,
|
SUGGESTIONS_DISMISS,
|
||||||
} from '../actions/suggestions';
|
} from '../actions/suggestions';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
|
||||||
return state.set('isLoading', false);
|
return state.set('isLoading', false);
|
||||||
case SUGGESTIONS_DISMISS:
|
case SUGGESTIONS_DISMISS:
|
||||||
return state.update('items', list => list.filterNot(id => id === action.id));
|
return state.update('items', list => list.filterNot(id => id === action.id));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => id === action.relationship.id));
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -445,7 +445,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosuggest-account,
|
.autosuggest-account,
|
||||||
.autosuggest-emoji {
|
.autosuggest-emoji,
|
||||||
|
.autosuggest-hashtag {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -454,6 +455,14 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.autosuggest-hashtag {
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.autosuggest-account-icon,
|
.autosuggest-account-icon,
|
||||||
.autosuggest-emoji img {
|
.autosuggest-emoji img {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -753,6 +762,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.unhandled-link {
|
||||||
|
color: lighten($ui-highlight-color, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
.status__content__spoiler-link {
|
||||||
background: $action-button-color;
|
background: $action-button-color;
|
||||||
|
|
||||||
|
@ -1936,6 +1949,9 @@ a.account__display-name {
|
||||||
background: lighten($ui-base-color, 8%);
|
background: lighten($ui-base-color, 8%);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-bar__link {
|
.tabs-bar__link {
|
||||||
|
@ -4006,8 +4022,9 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results__info {
|
.search-results__info {
|
||||||
padding: 10px;
|
padding: 20px;
|
||||||
color: $secondary-text-color;
|
color: $darker-text-color;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-root {
|
.modal-root {
|
||||||
|
|
|
@ -148,12 +148,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
def process_hashtag(tag)
|
def process_hashtag(tag)
|
||||||
return if tag['name'].blank?
|
return if tag['name'].blank?
|
||||||
|
|
||||||
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
Tag.find_or_create_by_names(tag['name']) do |hashtag|
|
||||||
hashtag = Tag.where(name: hashtag).first_or_create!(name: hashtag)
|
@tags << hashtag unless @tags.include?(hashtag)
|
||||||
|
end
|
||||||
return if @tags.include?(hashtag)
|
|
||||||
|
|
||||||
@tags << hashtag
|
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SearchQueryParser < Parslet::Parser
|
||||||
|
rule(:term) { match('[^\s":]').repeat(1).as(:term) }
|
||||||
|
rule(:quote) { str('"') }
|
||||||
|
rule(:colon) { str(':') }
|
||||||
|
rule(:space) { match('\s').repeat(1) }
|
||||||
|
rule(:operator) { (str('+') | str('-')).as(:operator) }
|
||||||
|
rule(:prefix) { (term >> colon).as(:prefix) }
|
||||||
|
rule(:phrase) { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) }
|
||||||
|
rule(:clause) { (prefix.maybe >> operator.maybe >> (phrase | term)).as(:clause) }
|
||||||
|
rule(:query) { (clause >> space.maybe).repeat.as(:query) }
|
||||||
|
root(:query)
|
||||||
|
end
|
|
@ -0,0 +1,86 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SearchQueryTransformer < Parslet::Transform
|
||||||
|
class Query
|
||||||
|
attr_reader :should_clauses, :must_not_clauses, :must_clauses
|
||||||
|
|
||||||
|
def initialize(clauses)
|
||||||
|
grouped = clauses.chunk(&:operator).to_h
|
||||||
|
@should_clauses = grouped.fetch(:should, [])
|
||||||
|
@must_not_clauses = grouped.fetch(:must_not, [])
|
||||||
|
@must_clauses = grouped.fetch(:must, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply(search)
|
||||||
|
should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) }
|
||||||
|
must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) }
|
||||||
|
must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) }
|
||||||
|
search.query.minimum_should_match(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def clause_to_query(clause)
|
||||||
|
case clause
|
||||||
|
when TermClause
|
||||||
|
{ multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } }
|
||||||
|
when PhraseClause
|
||||||
|
{ match_phrase: { text: { query: clause.phrase } } }
|
||||||
|
else
|
||||||
|
raise "Unexpected clause type: #{clause}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Operator
|
||||||
|
class << self
|
||||||
|
def symbol(str)
|
||||||
|
case str
|
||||||
|
when '+'
|
||||||
|
:must
|
||||||
|
when '-'
|
||||||
|
:must_not
|
||||||
|
when nil
|
||||||
|
:should
|
||||||
|
else
|
||||||
|
raise "Unknown operator: #{str}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TermClause
|
||||||
|
attr_reader :prefix, :operator, :term
|
||||||
|
|
||||||
|
def initialize(prefix, operator, term)
|
||||||
|
@prefix = prefix
|
||||||
|
@operator = Operator.symbol(operator)
|
||||||
|
@term = term
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class PhraseClause
|
||||||
|
attr_reader :prefix, :operator, :phrase
|
||||||
|
|
||||||
|
def initialize(prefix, operator, phrase)
|
||||||
|
@prefix = prefix
|
||||||
|
@operator = Operator.symbol(operator)
|
||||||
|
@phrase = phrase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(clause: subtree(:clause)) do
|
||||||
|
prefix = clause[:prefix][:term].to_s if clause[:prefix]
|
||||||
|
operator = clause[:operator]&.to_s
|
||||||
|
|
||||||
|
if clause[:term]
|
||||||
|
TermClause.new(prefix, operator, clause[:term].to_s)
|
||||||
|
elsif clause[:phrase]
|
||||||
|
PhraseClause.new(prefix, operator, clause[:phrase].map { |p| p[:term].to_s }.join(' '))
|
||||||
|
else
|
||||||
|
raise "Unexpected clause type: #{clause}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(query: sequence(:clauses)) { Query.new(clauses) }
|
||||||
|
end
|
|
@ -17,7 +17,7 @@
|
||||||
class Invite < ApplicationRecord
|
class Invite < ApplicationRecord
|
||||||
include Expireable
|
include Expireable
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user, inverse_of: :invites
|
||||||
has_many :users, inverse_of: :invite
|
has_many :users, inverse_of: :invite
|
||||||
|
|
||||||
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
||||||
|
@ -25,7 +25,7 @@ class Invite < ApplicationRecord
|
||||||
before_validation :set_code
|
before_validation :set_code
|
||||||
|
|
||||||
def valid_for_use?
|
def valid_for_use?
|
||||||
(max_uses.nil? || uses < max_uses) && !expired?
|
(max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -113,7 +113,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
has_attached_file :file,
|
has_attached_file :file,
|
||||||
styles: ->(f) { file_styles f },
|
styles: ->(f) { file_styles f },
|
||||||
processors: ->(f) { file_processors f },
|
processors: ->(f) { file_processors f },
|
||||||
convert_options: { all: '-quality 90 -strip' }
|
convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
|
||||||
|
|
||||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
||||||
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Tag < ApplicationRecord
|
||||||
HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||||
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
||||||
|
|
||||||
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
||||||
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
||||||
|
@ -64,22 +64,48 @@ class Tag < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def search_for(term, limit = 5, offset = 0)
|
def find_or_create_by_names(name_or_names)
|
||||||
pattern = sanitize_sql_like(term.strip) + '%'
|
Array(name_or_names).map(&method(:normalize)).uniq.map do |normalized_name|
|
||||||
|
tag = matching_name(normalized_name).first || create(name: normalized_name)
|
||||||
|
|
||||||
Tag.where('lower(name) like lower(?)', pattern)
|
yield tag if block_given?
|
||||||
|
|
||||||
|
tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_for(term, limit = 5, offset = 0)
|
||||||
|
pattern = sanitize_sql_like(normalize(term.strip)) + '%'
|
||||||
|
|
||||||
|
Tag.where(arel_table[:name].lower.matches(pattern.downcase))
|
||||||
.order(:name)
|
.order(:name)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_normalized(name)
|
def find_normalized(name)
|
||||||
find_by(name: name.mb_chars.downcase.to_s)
|
matching_name(name).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_normalized!(name)
|
def find_normalized!(name)
|
||||||
find_normalized(name) || raise(ActiveRecord::RecordNotFound)
|
find_normalized(name) || raise(ActiveRecord::RecordNotFound)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def matching_name(name_or_names)
|
||||||
|
names = Array(name_or_names).map { |name| normalize(name).downcase }
|
||||||
|
|
||||||
|
if names.size == 1
|
||||||
|
where(arel_table[:name].lower.eq(names.first))
|
||||||
|
else
|
||||||
|
where(arel_table[:name].lower.in(names))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize(str)
|
||||||
|
str.gsub(/\A#/, '').mb_chars.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -73,6 +73,7 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
||||||
has_many :backups, inverse_of: :user
|
has_many :backups, inverse_of: :user
|
||||||
|
has_many :invites, inverse_of: :user
|
||||||
|
|
||||||
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
|
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
|
||||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
||||||
|
|
|
@ -10,12 +10,24 @@ class AfterBlockDomainFromAccountService < BaseService
|
||||||
@account = account
|
@account = account
|
||||||
@domain = domain
|
@domain = domain
|
||||||
|
|
||||||
|
clear_notifications!
|
||||||
|
remove_follows!
|
||||||
reject_existing_followers!
|
reject_existing_followers!
|
||||||
reject_pending_follow_requests!
|
reject_pending_follow_requests!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remove_follows!
|
||||||
|
@account.active_relationships.where(account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow|
|
||||||
|
UnfollowService.new.call(@account, follow.target_account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_notifications!
|
||||||
|
Notification.where(account: @account).where(from_account: Account.where(domain: @domain)).in_batches.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
def reject_existing_followers!
|
def reject_existing_followers!
|
||||||
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
|
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
|
||||||
reject_follow!(follow)
|
reject_follow!(follow)
|
||||||
|
|
|
@ -2,43 +2,25 @@
|
||||||
|
|
||||||
class AfterBlockService < BaseService
|
class AfterBlockService < BaseService
|
||||||
def call(account, target_account)
|
def call(account, target_account)
|
||||||
clear_home_feed(account, target_account)
|
@account = account
|
||||||
clear_notifications(account, target_account)
|
@target_account = target_account
|
||||||
clear_conversations(account, target_account)
|
|
||||||
|
clear_home_feed!
|
||||||
|
clear_notifications!
|
||||||
|
clear_conversations!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def clear_home_feed(account, target_account)
|
def clear_home_feed!
|
||||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
FeedManager.instance.clear_from_timeline(@account, @target_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_conversations(account, target_account)
|
def clear_conversations!
|
||||||
AccountConversation.where(account: account)
|
AccountConversation.where(account: @account).where('? = ANY(participant_account_ids)', @target_account.id).in_batches.destroy_all
|
||||||
.where('? = ANY(participant_account_ids)', target_account.id)
|
|
||||||
.in_batches
|
|
||||||
.destroy_all
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_notifications(account, target_account)
|
def clear_notifications!
|
||||||
Notification.where(account: account)
|
Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
|
||||||
.joins(:follow)
|
|
||||||
.where(activity_type: 'Follow', follows: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(mention: :status)
|
|
||||||
.where(activity_type: 'Mention', statuses: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(:favourite)
|
|
||||||
.where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(:status)
|
|
||||||
.where(activity_type: 'Status', statuses: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,7 +13,7 @@ class FollowService < BaseService
|
||||||
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
|
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
|
||||||
|
|
||||||
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
|
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
|
||||||
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || (!target_account.local? && target_account.ostatus?)
|
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || (!target_account.local? && target_account.ostatus?) || source_account.domain_blocking?(target_account.domain)
|
||||||
|
|
||||||
if source_account.following?(target_account)
|
if source_account.following?(target_account)
|
||||||
# We're already following this account, but we'll call follow! again to
|
# We're already following this account, but we'll call follow! again to
|
||||||
|
|
|
@ -14,7 +14,7 @@ class HashtagQueryService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def tags_for(tags)
|
def tags_for(names)
|
||||||
Tag.where(name: tags.map(&:downcase)) if tags.presence
|
Tag.matching_name(names) if names.presence
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,9 +5,7 @@ class ProcessHashtagsService < BaseService
|
||||||
tags = Extractor.extract_hashtags(status.text) if status.local?
|
tags = Extractor.extract_hashtags(status.text) if status.local?
|
||||||
records = []
|
records = []
|
||||||
|
|
||||||
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
|
Tag.find_or_create_by_names(tags) do |tag|
|
||||||
tag = Tag.where(name: name).first_or_create(name: name)
|
|
||||||
|
|
||||||
status.tags << tag
|
status.tags << tag
|
||||||
records << tag
|
records << tag
|
||||||
|
|
||||||
|
|
|
@ -33,8 +33,7 @@ class SearchService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_statuses_search!
|
def perform_statuses_search!
|
||||||
definition = StatusesIndex.filter(term: { searchable_by: @account.id })
|
definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id }))
|
||||||
.query(multi_match: { type: 'most_fields', query: @query, operator: 'and', fields: %w(text text.stemmed) })
|
|
||||||
|
|
||||||
if @options[:account_id].present?
|
if @options[:account_id].present?
|
||||||
definition = definition.filter(term: { account_id: @options[:account_id] })
|
definition = definition.filter(term: { account_id: @options[:account_id] })
|
||||||
|
@ -70,7 +69,7 @@ class SearchService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def url_query?
|
def url_query?
|
||||||
@options[:type].blank? && @query =~ /\Ahttps?:\/\//
|
@resolve && @options[:type].blank? && @query =~ /\Ahttps?:\/\//
|
||||||
end
|
end
|
||||||
|
|
||||||
def url_resource_results
|
def url_resource_results
|
||||||
|
@ -120,4 +119,8 @@ class SearchService < BaseService
|
||||||
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
|
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parsed_query
|
||||||
|
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -64,6 +64,7 @@ class SuspendAccountService < BaseService
|
||||||
@account.user.destroy
|
@account.user.destroy
|
||||||
else
|
else
|
||||||
@account.user.disable!
|
@account.user.disable!
|
||||||
|
@account.user.invites.where(uses: 0).destroy_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
%meta{ name: 'description', content: account_description(@account) }/
|
%meta{ name: 'description', content: account_description(@account) }/
|
||||||
|
|
||||||
- if @account.user&.setting_noindex
|
- if @account.user&.setting_noindex
|
||||||
%meta{ name: 'robots', content: 'noindex' }/
|
%meta{ name: 'robots', content: 'noindex, noarchive' }/
|
||||||
|
|
||||||
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
|
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
|
||||||
%link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
|
%link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
- content_for :header_tags do
|
- content_for :header_tags do
|
||||||
- if @account.user&.setting_noindex
|
- if @account.user&.setting_noindex
|
||||||
%meta{ name: 'robots', content: 'noindex' }/
|
%meta{ name: 'robots', content: 'noindex, noarchive' }/
|
||||||
|
|
||||||
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/
|
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/
|
||||||
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/
|
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Scheduler::PreviewCardsCleanupScheduler
|
|
||||||
include Sidekiq::Worker
|
|
||||||
|
|
||||||
sidekiq_options unique: :until_executed, retry: 0
|
|
||||||
|
|
||||||
def perform
|
|
||||||
Maintenance::UncachePreviewWorker.push_bulk(recent_link_preview_cards.pluck(:id))
|
|
||||||
Maintenance::UncachePreviewWorker.push_bulk(older_preview_cards.pluck(:id))
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def recent_link_preview_cards
|
|
||||||
PreviewCard.where(type: :link).where('updated_at < ?', 1.month.ago)
|
|
||||||
end
|
|
||||||
|
|
||||||
def older_preview_cards
|
|
||||||
PreviewCard.where('updated_at < ?', 6.months.ago)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -4,13 +4,13 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
navigation.items do |n|
|
navigation.items do |n|
|
||||||
n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url
|
n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url
|
||||||
|
|
||||||
n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_url do |s|
|
n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_url, if: -> { current_user.functional? } do |s|
|
||||||
s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration}
|
s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration}
|
||||||
s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url
|
s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url
|
||||||
s.item :identity_proofs, safe_join([fa_icon('key fw'), t('settings.identity_proofs')]), settings_identity_proofs_path, highlights_on: %r{/settings/identity_proofs*}, if: proc { current_account.identity_proofs.exists? }
|
s.item :identity_proofs, safe_join([fa_icon('key fw'), t('settings.identity_proofs')]), settings_identity_proofs_path, highlights_on: %r{/settings/identity_proofs*}, if: proc { current_account.identity_proofs.exists? }
|
||||||
end
|
end
|
||||||
|
|
||||||
n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url do |s|
|
n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s|
|
||||||
s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url
|
s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url
|
||||||
s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url
|
s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url
|
||||||
s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
|
s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
|
||||||
|
@ -22,8 +22,8 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url
|
n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url, if: -> { current_user.functional? }
|
||||||
n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}
|
n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
|
||||||
|
|
||||||
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
|
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
|
||||||
s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
|
s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
|
||||||
|
@ -31,13 +31,13 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
|
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
|
||||||
end
|
end
|
||||||
|
|
||||||
n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url do |s|
|
n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url, if: -> { current_user.functional? } do |s|
|
||||||
s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url
|
s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url
|
||||||
s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
|
s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
|
||||||
end
|
end
|
||||||
|
|
||||||
n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' }
|
n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? }
|
||||||
n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url
|
n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
|
||||||
|
|
||||||
n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s|
|
n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s|
|
||||||
s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
|
s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
|
||||||
|
|
|
@ -24,9 +24,6 @@
|
||||||
ip_cleanup_scheduler:
|
ip_cleanup_scheduler:
|
||||||
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
|
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
|
||||||
class: Scheduler::IpCleanupScheduler
|
class: Scheduler::IpCleanupScheduler
|
||||||
preview_cards_cleanup_scheduler:
|
|
||||||
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
|
|
||||||
class: Scheduler::PreviewCardsCleanupScheduler
|
|
||||||
email_scheduler:
|
email_scheduler:
|
||||||
cron: '0 10 * * 2'
|
cron: '0 10 * * 2'
|
||||||
class: Scheduler::EmailScheduler
|
class: Scheduler::EmailScheduler
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
class AddCaseInsensitiveIndexToTags < ActiveRecord::Migration[5.2]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
safety_assured { execute 'CREATE UNIQUE INDEX CONCURRENTLY index_tags_on_name_lower ON tags (lower(name))' }
|
||||||
|
remove_index :tags, name: 'index_tags_on_name'
|
||||||
|
remove_index :tags, name: 'hashtag_search_index'
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
add_index :tags, :name, unique: true, algorithm: :concurrently
|
||||||
|
safety_assured { execute 'CREATE INDEX CONCURRENTLY hashtag_search_index ON tags (name text_pattern_ops)' }
|
||||||
|
remove_index :tags, name: 'index_tags_on_name_lower'
|
||||||
|
end
|
||||||
|
end
|
|
@ -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: 2019_07_15_164535) do
|
ActiveRecord::Schema.define(version: 2019_07_26_175042) 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"
|
||||||
|
@ -665,8 +665,7 @@ ActiveRecord::Schema.define(version: 2019_07_15_164535) do
|
||||||
t.string "name", default: "", null: false
|
t.string "name", default: "", null: false
|
||||||
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.index "lower((name)::text) text_pattern_ops", name: "hashtag_search_index"
|
t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
|
||||||
t.index ["name"], name: "index_tags_on_name", unique: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "tombstones", force: :cascade do |t|
|
create_table "tombstones", force: :cascade do |t|
|
||||||
|
|
|
@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli'
|
||||||
require_relative 'mastodon/settings_cli'
|
require_relative 'mastodon/settings_cli'
|
||||||
require_relative 'mastodon/statuses_cli'
|
require_relative 'mastodon/statuses_cli'
|
||||||
require_relative 'mastodon/domains_cli'
|
require_relative 'mastodon/domains_cli'
|
||||||
|
require_relative 'mastodon/preview_cards_cli'
|
||||||
require_relative 'mastodon/cache_cli'
|
require_relative 'mastodon/cache_cli'
|
||||||
require_relative 'mastodon/version'
|
require_relative 'mastodon/version'
|
||||||
|
|
||||||
|
@ -42,6 +43,9 @@ module Mastodon
|
||||||
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
|
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
|
||||||
subcommand 'domains', Mastodon::DomainsCLI
|
subcommand 'domains', Mastodon::DomainsCLI
|
||||||
|
|
||||||
|
desc 'preview_cards SUBCOMMAND ...ARGS', 'Manage preview cards'
|
||||||
|
subcommand 'preview_cards', Mastodon::PreviewCardsCLI
|
||||||
|
|
||||||
desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
|
desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
|
||||||
subcommand 'cache', Mastodon::CacheCLI
|
subcommand 'cache', Mastodon::CacheCLI
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
# This file generated automatically from https://w3id.org/security/v1
|
# This file generated automatically from http://w3id.org/security/v1
|
||||||
require 'json/ld'
|
require 'json/ld'
|
||||||
class JSON::LD::Context
|
class JSON::LD::Context
|
||||||
add_preloaded("https://w3id.org/security/v1") do
|
add_preloaded("http://w3id.org/security/v1") do
|
||||||
new(processingMode: "json-ld-1.0", term_definitions: {
|
new(processingMode: "json-ld-1.0", term_definitions: {
|
||||||
"CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true),
|
"CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true),
|
||||||
"EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true),
|
"EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true),
|
||||||
|
@ -47,4 +47,5 @@ class JSON::LD::Context
|
||||||
"xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true)
|
"xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
alias_preloaded("https://w3id.org/security/v1", "http://w3id.org/security/v1")
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'tty-prompt'
|
||||||
|
require_relative '../../config/boot'
|
||||||
|
require_relative '../../config/environment'
|
||||||
|
require_relative 'cli_helper'
|
||||||
|
|
||||||
|
module Mastodon
|
||||||
|
class PreviewCardsCLI < Thor
|
||||||
|
include ActionView::Helpers::NumberHelper
|
||||||
|
|
||||||
|
def self.exit_on_failure?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
option :days, type: :numeric, default: 180
|
||||||
|
option :background, type: :boolean, default: false
|
||||||
|
option :verbose, type: :boolean, default: false
|
||||||
|
option :dry_run, type: :boolean, default: false
|
||||||
|
option :link, type: :boolean, default: false
|
||||||
|
desc 'remove', 'Remove preview cards'
|
||||||
|
long_desc <<-DESC
|
||||||
|
Removes locally thumbnails for previews.
|
||||||
|
|
||||||
|
The --days option specifies how old preview cards have to be before
|
||||||
|
they are removed. It defaults to 180 days.
|
||||||
|
|
||||||
|
With the --background option, instead of deleting the files sequentially,
|
||||||
|
they will be queued into Sidekiq and the command will exit as soon as
|
||||||
|
possible. In Sidekiq they will be processed with higher concurrency, but
|
||||||
|
it may impact other operations of the Mastodon server, and it may overload
|
||||||
|
the underlying file storage.
|
||||||
|
|
||||||
|
With the --dry-run option, no work will be done.
|
||||||
|
|
||||||
|
With the --verbose option, when preview cards are processed sequentially in the
|
||||||
|
foreground, the IDs of the preview cards will be printed.
|
||||||
|
|
||||||
|
With the --link option, delete only link-type preview cards.
|
||||||
|
DESC
|
||||||
|
def remove
|
||||||
|
prompt = TTY::Prompt.new
|
||||||
|
time_ago = options[:days].days.ago
|
||||||
|
queued = 0
|
||||||
|
processed = 0
|
||||||
|
size = 0
|
||||||
|
dry_run = options[:dry_run] ? '(DRY RUN)' : ''
|
||||||
|
link = options[:link] ? 'link-type ' : ''
|
||||||
|
scope = PreviewCard.where.not(image_file_name: nil)
|
||||||
|
scope = scope.where.not(image_file_name: '')
|
||||||
|
scope = scope.where(type: :link) if options[:link]
|
||||||
|
scope = scope.where('updated_at < ?', time_ago)
|
||||||
|
|
||||||
|
if time_ago > 2.weeks.ago
|
||||||
|
prompt.say "\n"
|
||||||
|
prompt.say('The preview cards less than the past two weeks will not be re-acquired even when needed.')
|
||||||
|
prompt.say "\n"
|
||||||
|
|
||||||
|
unless prompt.yes?('Are you sure you want to delete the preview cards?', default: false)
|
||||||
|
prompt.say "\n"
|
||||||
|
prompt.warn 'Nothing execute. Bye!'
|
||||||
|
prompt.say "\n"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if options[:background]
|
||||||
|
scope.select(:id, :image_file_size).reorder(nil).find_in_batches do |preview_cards|
|
||||||
|
queued += preview_cards.size
|
||||||
|
size += preview_cards.reduce(0) { |sum, p| sum + (p.image_file_size || 0) }
|
||||||
|
Maintenance::UncachePreviewWorker.push_bulk(preview_cards.map(&:id)) unless options[:dry_run]
|
||||||
|
end
|
||||||
|
|
||||||
|
else
|
||||||
|
scope.select(:id, :image_file_size).reorder(nil).find_in_batches do |preview_cards|
|
||||||
|
preview_cards.each do |p|
|
||||||
|
size += p.image_file_size || 0
|
||||||
|
Maintenance::UncachePreviewWorker.new.perform(p.id) unless options[:dry_run]
|
||||||
|
options[:verbose] ? say(p.id) : say('.', :green, false)
|
||||||
|
processed += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say
|
||||||
|
|
||||||
|
if options[:background]
|
||||||
|
say("Scheduled the deletion of #{queued} #{link}preview cards (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
|
||||||
|
else
|
||||||
|
say("Removed #{processed} #{link}preview cards (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -16,22 +16,18 @@ module Mastodon
|
||||||
2
|
2
|
||||||
end
|
end
|
||||||
|
|
||||||
def pre
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def flags
|
def flags
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_a
|
|
||||||
[major, minor, patch, pre].compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def suffix
|
def suffix
|
||||||
'+glitch'
|
'+glitch'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_a
|
||||||
|
[major, minor, patch].compact
|
||||||
|
end
|
||||||
|
|
||||||
def to_s
|
def to_s
|
||||||
[to_a.join('.'), flags, suffix].join
|
[to_a.join('.'), flags, suffix].join
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe SpamCheck do
|
RSpec.describe SpamCheck do
|
||||||
|
@ -133,7 +135,31 @@ RSpec.describe SpamCheck do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#remember!' do
|
describe '#remember!' do
|
||||||
pending
|
let(:status) { status_with_html('@alice') }
|
||||||
|
let(:spam_check) { described_class.new(status) }
|
||||||
|
let(:redis_key) { spam_check.send(:redis_key) }
|
||||||
|
|
||||||
|
it 'remembers' do
|
||||||
|
expect do
|
||||||
|
spam_check.remember!
|
||||||
|
end.to change { Redis.current.exists(redis_key) }.from(false).to(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#reset!' do
|
||||||
|
let(:status) { status_with_html('@alice') }
|
||||||
|
let(:spam_check) { described_class.new(status) }
|
||||||
|
let(:redis_key) { spam_check.send(:redis_key) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
spam_check.remember!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resets' do
|
||||||
|
expect do
|
||||||
|
spam_check.reset!
|
||||||
|
end.to change { Redis.current.exists(redis_key) }.from(true).to(false)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#flag!' do
|
describe '#flag!' do
|
||||||
|
|
|
@ -3,27 +3,33 @@ require 'rails_helper'
|
||||||
RSpec.describe Invite, type: :model do
|
RSpec.describe Invite, type: :model do
|
||||||
describe '#valid_for_use?' do
|
describe '#valid_for_use?' do
|
||||||
it 'returns true when there are no limitations' do
|
it 'returns true when there are no limitations' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true when not expired' do
|
it 'returns true when not expired' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false when expired' do
|
it 'returns false when expired' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
|
||||||
expect(invite.valid_for_use?).to be false
|
expect(invite.valid_for_use?).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true when uses still available' do
|
it 'returns true when uses still available' do
|
||||||
invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: 250, uses: 249, expires_at: nil)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false when maximum uses reached' do
|
it 'returns false when maximum uses reached' do
|
||||||
invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: 250, uses: 250, expires_at: nil)
|
||||||
|
expect(invite.valid_for_use?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when invite creator has been disabled' do
|
||||||
|
invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
|
||||||
|
SuspendAccountService.new.call(invite.user.account)
|
||||||
expect(invite.valid_for_use?).to be false
|
expect(invite.valid_for_use?).to be false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe PollVote, type: :model do
|
RSpec.describe PollVote, type: :model do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
describe '#object_type' do
|
||||||
|
let(:poll_vote) { Fabricate.build(:poll_vote) }
|
||||||
|
|
||||||
|
it 'returns :vote' do
|
||||||
|
expect(poll_vote.object_type).to eq :vote
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe SearchService, type: :service do
|
||||||
it 'returns the empty results' do
|
it 'returns the empty results' do
|
||||||
service = double(call: nil)
|
service = double(call: nil)
|
||||||
allow(ResolveURLService).to receive(:new).and_return(service)
|
allow(ResolveURLService).to receive(:new).and_return(service)
|
||||||
results = subject.call(@query, nil, 10)
|
results = subject.call(@query, nil, 10, resolve: true)
|
||||||
|
|
||||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
||||||
expect(results).to eq empty_results
|
expect(results).to eq empty_results
|
||||||
|
@ -40,7 +40,7 @@ describe SearchService, type: :service do
|
||||||
service = double(call: account)
|
service = double(call: account)
|
||||||
allow(ResolveURLService).to receive(:new).and_return(service)
|
allow(ResolveURLService).to receive(:new).and_return(service)
|
||||||
|
|
||||||
results = subject.call(@query, nil, 10)
|
results = subject.call(@query, nil, 10, resolve: true)
|
||||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
||||||
expect(results).to eq empty_results.merge(accounts: [account])
|
expect(results).to eq empty_results.merge(accounts: [account])
|
||||||
end
|
end
|
||||||
|
@ -52,7 +52,7 @@ describe SearchService, type: :service do
|
||||||
service = double(call: status)
|
service = double(call: status)
|
||||||
allow(ResolveURLService).to receive(:new).and_return(service)
|
allow(ResolveURLService).to receive(:new).and_return(service)
|
||||||
|
|
||||||
results = subject.call(@query, nil, 10)
|
results = subject.call(@query, nil, 10, resolve: true)
|
||||||
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
|
||||||
expect(results).to eq empty_results.merge(statuses: [status])
|
expect(results).to eq empty_results.merge(statuses: [status])
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue