Merge branch 'master' into glitch-soc/merge-upstream

This commit is contained in:
Thibaut Girka
2018-05-29 13:52:26 +02:00
37 changed files with 553 additions and 93 deletions

View File

@@ -23,15 +23,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
end
def direct_statuses
direct_timeline_statuses.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
direct_timeline_statuses
end
def direct_timeline_statuses
Status.as_direct_timeline(current_account)
# this query requires built in pagination.
Status.as_direct_timeline(
current_account,
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id],
true # returns array of cache_ids object
)
end
def insert_pagination_headers

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class Api::V1::TrendsController < Api::BaseController
before_action :set_tags
respond_to :json
def index
render json: @tags, each_serializer: REST::TagSerializer
end
private
def set_tags
@tags = TrendingTags.get(limit_param(10))
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class Api::V2::SearchController < Api::V1::SearchController
def index
@search = Search.new(search)
render json: @search, serializer: REST::V2::SearchSerializer
end
end

View File

@@ -33,7 +33,7 @@ export function submitSearch() {
dispatch(fetchSearchRequest());
api(getState).get('/api/v1/search', {
api(getState).get('/api/v2/search', {
params: {
q: value,
resolve: true,

View File

@@ -0,0 +1,32 @@
import api from '../api';
export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL';
export const fetchTrends = () => (dispatch, getState) => {
dispatch(fetchTrendsRequest());
api(getState)
.get('/api/v1/trends')
.then(({ data }) => dispatch(fetchTrendsSuccess(data)))
.catch(err => dispatch(fetchTrendsFail(err)));
};
export const fetchTrendsRequest = () => ({
type: TRENDS_FETCH_REQUEST,
skipLoading: true,
});
export const fetchTrendsSuccess = trends => ({
type: TRENDS_FETCH_SUCCESS,
trends,
skipLoading: true,
});
export const fetchTrendsFail = error => ({
type: TRENDS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
});

View File

@@ -25,6 +25,7 @@ export default class ScrollableList extends PureComponent {
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
children: PropTypes.node,
};
@@ -140,7 +141,7 @@ export default class ScrollableList extends PureComponent {
}
render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);
@@ -172,8 +173,12 @@ export default class ScrollableList extends PureComponent {
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={this.setRef}>
{emptyMessage}
<div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column' }}>
{alwaysPrepend && prepend}
<div className='empty-column-indicator' ref={this.setRef}>
{emptyMessage}
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
};
static defaultProps = {

View File

@@ -127,6 +127,7 @@ export default class CommunityTimeline extends React.PureComponent {
<StatusListContainer
prepend={headline}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`}

View File

@@ -1,28 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else {
return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
}
};
const renderHashtag = hashtag => (
<div className='trends__item' key={hashtag.get('name')}>
<div className='trends__item__name'>
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Link>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
export default class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
trends: ImmutablePropTypes.list,
fetchTrends: PropTypes.func.isRequired,
};
componentDidMount () {
const { fetchTrends } = this.props;
fetchTrends();
}
render () {
const { results } = this.props;
const { results, trends } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (results.isEmpty()) {
return (
<div className='search-results'>
<div className='trends'>
<div className='trends__header'>
<i className='fa fa-fire fa-fw' />
<FormattedMessage id='trends.header' defaultMessage='Trending now' />
</div>
{trends && trends.map(hashtag => renderHashtag(hashtag))}
</div>
</div>
);
}
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
<h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
@@ -33,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
@@ -44,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
<h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => (
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
))}
{results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
</div>
);
}
@@ -58,6 +108,7 @@ export default class SearchResults extends ImmutablePureComponent {
return (
<div className='search-results'>
<div className='search-results__header'>
<i className='fa fa-search fa-fw' />
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</div>

View File

@@ -1,8 +1,14 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
import { fetchTrends } from '../../../actions/trends';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
trends: state.get('trends'),
});
export default connect(mapStateToProps)(SearchResults);
const mapDispatchToProps = dispatch => ({
fetchTrends: () => dispatch(fetchTrends()),
});
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);

View File

@@ -101,7 +101,7 @@ export default class Compose extends React.PureComponent {
{(multiColumn || isSearchPage) && <SearchContainer /> }
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
{!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
{multiColumn && (
@@ -109,7 +109,7 @@ export default class Compose extends React.PureComponent {
<img alt='' draggable='false' src={elephantUIPlane} />
</div>
)}
</div>
</div>}
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (

View File

@@ -127,6 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
<StatusListContainer
prepend={headline}
alwaysPrepend
timelineId={`public${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}

View File

@@ -62,31 +62,28 @@ const makeMapStateToProps = () => {
if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => {
function addAncestor(id) {
if (id) {
const inReplyTo = state.getIn(['contexts', 'inReplyTos', id]);
let id = status.get('in_reply_to_id');
mutable.unshift(id);
addAncestor(inReplyTo);
}
while (id) {
mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', id]);
}
addAncestor(status.get('in_reply_to_id'));
});
descendantsIds = descendantsIds.withMutations(mutable => {
function addDescendantOf(id) {
const ids = [status.get('id')];
while (ids.length > 0) {
let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]);
if (replies) {
replies.forEach(reply => {
mutable.push(reply);
addDescendantOf(reply);
ids.unshift(reply);
});
}
}
addDescendantOf(status.get('id'));
});
}

View File

@@ -26,6 +26,7 @@ import height_cache from './height_cache';
import custom_emojis from './custom_emojis';
import lists from './lists';
import listEditor from './list_editor';
import trends from './trends';
const reducers = {
dropdown_menu,
@@ -55,6 +56,7 @@ const reducers = {
custom_emojis,
lists,
listEditor,
trends,
};
export default combineReducers(reducers);

View File

@@ -9,7 +9,7 @@ import {
COMPOSE_REPLY,
COMPOSE_DIRECT,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
value: '',
@@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
return state.set('results', ImmutableMap({
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: ImmutableList(action.results.hashtags),
hashtags: fromJS(action.results.hashtags),
})).set('submitted', true);
default:
return state;

View File

@@ -0,0 +1,13 @@
import { TRENDS_FETCH_SUCCESS } from '../actions/trends';
import { fromJS } from 'immutable';
const initialState = null;
export default function trendsReducer(state = initialState, action) {
switch(action.type) {
case TRENDS_FETCH_SUCCESS:
return fromJS(action.trends);
default:
return state;
}
};

View File

@@ -49,7 +49,7 @@ self.addEventListener('fetch', function(event) {
return response;
}));
} else if (storageFreeable && process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) {
} else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
event.respondWith(openSystemCache().then(cache => {
return cache.match(event.request.url).then(cached => {
if (cached === undefined) {

View File

@@ -132,20 +132,54 @@
.boost-modal,
.confirmation-modal,
.mute-modal,
.report-modal {
.report-modal,
.embed-modal,
.error-modal,
.onboarding-modal {
background: $ui-base-color;
}
.boost-modal__action-bar,
.confirmation-modal__action-bar,
.mute-modal__action-bar {
.mute-modal__action-bar,
.onboarding-modal__paginator,
.error-modal__footer {
background: darken($ui-base-color, 6%);
.onboarding-modal__nav,
.error-modal__nav {
&:hover,
&:focus,
&:active {
background-color: darken($ui-base-color, 12%);
}
}
}
.display-case__case {
background: $white;
}
.embed-modal .embed-modal__container .embed-modal__html {
background: $white;
&:focus {
background: darken($ui-base-color, 6%);
}
}
.react-toggle-track {
background: $ui-secondary-color;
}
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
background: darken($ui-secondary-color, 10%);
}
.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
background: lighten($ui-highlight-color, 10%);
}
// Change the default color used for the text in an empty column or on the error column
.empty-column-indicator,
.error-column {

View File

@@ -3284,6 +3284,15 @@ a.status-card {
}
.search__icon {
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus {
outline: 0 !important;
}
.fa {
position: absolute;
top: 10px;
@@ -3333,40 +3342,33 @@ a.status-card {
.search-results__header {
color: $dark-text-color;
background: lighten($ui-base-color, 2%);
border-bottom: 1px solid darken($ui-base-color, 4%);
padding: 15px 10px;
font-size: 14px;
padding: 15px;
font-weight: 500;
font-size: 16px;
cursor: default;
.fa {
display: inline-block;
margin-right: 5px;
}
}
.search-results__section {
margin-bottom: 20px;
margin-bottom: 5px;
h5 {
position: relative;
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: default;
display: flex;
padding: 15px;
font-weight: 500;
font-size: 16px;
color: $dark-text-color;
&::before {
content: "";
display: block;
position: absolute;
left: 0;
right: 0;
top: 50%;
width: 100%;
height: 0;
border-top: 1px solid lighten($ui-base-color, 8%);
}
span {
.fa {
display: inline-block;
background: $ui-base-color;
color: $darker-text-color;
font-size: 14px;
font-weight: 500;
padding: 10px;
position: relative;
z-index: 1;
cursor: default;
margin-right: 5px;
}
}
@@ -5209,3 +5211,81 @@ noscript {
background: $ui-base-color;
}
}
.trends {
&__header {
color: $dark-text-color;
background: lighten($ui-base-color, 2%);
border-bottom: 1px solid darken($ui-base-color, 4%);
font-weight: 500;
padding: 15px;
font-size: 16px;
cursor: default;
.fa {
display: inline-block;
margin-right: 5px;
}
}
&__item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
&__name {
flex: 1 1 auto;
color: $dark-text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
strong {
font-weight: 500;
}
a {
color: $darker-text-color;
text-decoration: none;
font-size: 14px;
font-weight: 500;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover,
&:focus,
&:active {
span {
text-decoration: underline;
}
}
}
}
&__current {
flex: 0 0 auto;
width: 100px;
font-size: 24px;
line-height: 36px;
font-weight: 500;
text-align: center;
color: $secondary-text-color;
}
&__sparkline {
flex: 0 0 auto;
width: 50px;
path {
stroke: lighten($highlight-text-color, 6%) !important;
}
}
}
}

View File

@@ -78,9 +78,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if tag['name'].blank?
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
status.tags << hashtag unless status.tags.include?(hashtag)
return if status.tags.include?(hashtag)
status.tags << hashtag
TrendingTags.record_use!(hashtag, status.account, status.created_at)
rescue ActiveRecord::RecordInvalid
nil
end

View File

@@ -354,7 +354,7 @@ class OStatus::AtomSerializer
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
status.mentions.order(:id).each do |mentioned|
status.mentions.sort_by(&:id).each do |mentioned|
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
end

View File

@@ -41,7 +41,7 @@ module Remotable
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
nil
rescue Paperclip::Error => e
rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
nil
end

View File

@@ -195,12 +195,45 @@ class Status < ApplicationRecord
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
end
def as_direct_timeline(account)
query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
.where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
.where(visibility: [:direct])
def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
# direct timeline is mix of direct message from_me and to_me.
# 2 querys are executed with pagination.
# constant expression using arel_table is required for partial index
apply_timeline_filters(query, account, false)
# _from_me part does not require any timeline filters
query_from_me = where(account_id: account.id)
.where(Status.arel_table[:visibility].eq(3))
.limit(limit)
.order('statuses.id DESC')
# _to_me part requires mute and block filter.
# FIXME: may we check mutes.hide_notifications?
query_to_me = Status
.joins(:mentions)
.merge(Mention.where(account_id: account.id))
.where(Status.arel_table[:visibility].eq(3))
.limit(limit)
.order('mentions.status_id DESC')
.not_excluded_by_account(account)
if max_id.present?
query_from_me = query_from_me.where('statuses.id < ?', max_id)
query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
end
if since_id.present?
query_from_me = query_from_me.where('statuses.id > ?', since_id)
query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
end
if cache_ids
# returns array of cache_ids object that have id and updated_at
(query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
else
# returns ActiveRecord.Relation
items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
Status.where(id: items.map(&:id))
end
end
def as_public_timeline(account = nil, local_only = false)

View File

@@ -21,6 +21,22 @@ class Tag < ApplicationRecord
name
end
def history
days = []
7.times do |i|
day = i.days.ago.beginning_of_day.to_i
days << {
day: day.to_s,
uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
}
end
days
end
class << self
def search_for(term, limit = 5)
pattern = sanitize_sql_like(term.strip) + '%'

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
class TrendingTags
KEY = 'trending_tags'
HALF_LIFE = 1.day.to_i
MAX_ITEMS = 500
EXPIRE_HISTORY_AFTER = 7.days.seconds
class << self
def record_use!(tag, account, at_time = Time.now.utc)
return if disallowed_hashtags.include?(tag.name) || account.silenced?
increment_vote!(tag.id, at_time)
increment_historical_use!(tag.id, at_time)
increment_unique_use!(tag.id, account.id, at_time)
end
def get(limit)
tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i)
tags = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
tag_ids.map { |tag_id| tags[tag_id] }.compact
end
private
def increment_vote!(tag_id, at_time)
redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s)
redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS)
end
def increment_historical_use!(tag_id, at_time)
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
redis.incrby(key, 1)
redis.expire(key, EXPIRE_HISTORY_AFTER)
end
def increment_unique_use!(tag_id, account_id, at_time)
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
redis.pfadd(key, account_id)
redis.expire(key, EXPIRE_HISTORY_AFTER)
end
# The epoch needs to be 2.5 years in the future if the half-life is one day
# While dynamic, it will always be the same within one year
def epoch
@epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i
end
def disallowed_hashtags
return @disallowed_hashtags if defined?(@disallowed_hashtags)
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
end
def redis
Redis.current
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::TagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url, :history
def url
tag_url(object)
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class REST::V2::SearchSerializer < ActiveModel::Serializer
has_many :accounts, serializer: REST::AccountSerializer
has_many :statuses, serializer: REST::StatusSerializer
has_many :hashtags, serializer: REST::TagSerializer
end

View File

@@ -4,8 +4,10 @@ class ProcessHashtagsService < BaseService
def call(status, tags = [])
tags = Extractor.extract_hashtags(status.text) if status.local?
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
tag = Tag.where(name: name).first_or_create(name: name)
status.tags << tag
TrendingTags.record_use!(tag, status.account, status.created_at)
end
end
end

View File

@@ -4,7 +4,7 @@ require 'resolv'
class EmailMxValidator < ActiveModel::Validator
def validate(user)
return if Rails.env.test?
return if Rails.env.test? || Rails.env.development?
user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
end