From 9bd23dc4e51ba47283a8e3a66cd94b4e624a5235 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 27 May 2018 21:45:30 +0200 Subject: Track trending tags (#7638) * Track trending tags - Half-life of 1 day - Historical usage in daily buckets (last 7 days stored) - GET /api/v1/trends Fix #271 * Add trends to web UI * Don't render compose form on search route, adjust search results header * Disqualify tag from trends if it's in disallowed hashtags setting * Count distinct accounts using tag, ignore silenced accounts --- app/controllers/api/v1/trends_controller.rb | 17 +++++ app/javascript/mastodon/actions/trends.js | 32 +++++++++ .../features/compose/components/search_results.js | 59 ++++++++++++++- .../compose/containers/search_results_container.js | 8 ++- app/javascript/mastodon/features/compose/index.js | 4 +- app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/trends.js | 13 ++++ app/javascript/styles/mastodon/components.scss | 83 +++++++++++++++++++++- app/models/tag.rb | 16 +++++ app/models/trending_tags.rb | 61 ++++++++++++++++ app/serializers/rest/tag_serializer.rb | 11 +++ app/services/process_hashtags_service.rb | 6 +- 12 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 app/controllers/api/v1/trends_controller.rb create mode 100644 app/javascript/mastodon/actions/trends.js create mode 100644 app/javascript/mastodon/reducers/trends.js create mode 100644 app/models/trending_tags.rb create mode 100644 app/serializers/rest/tag_serializer.rb (limited to 'app') diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb new file mode 100644 index 000000000..bcea9857e --- /dev/null +++ b/app/controllers/api/v1/trends_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js new file mode 100644 index 000000000..853e4f60a --- /dev/null +++ b/app/javascript/mastodon/actions/trends.js @@ -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, +}); diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index 84455563c..f2655c14d 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -1,23 +1,75 @@ 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 ; + } else { + return K; + } +}; 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 ( +
+
+
+ + +
+ + {trends && trends.map(hashtag => ( +
+
+ + #{hashtag.get('name')} + + + {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))} }} /> +
+ +
+ {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} +
+ +
+ day.get('uses')).toArray()}> + + +
+
+ ))} +
+
+ ); + } + if (results.get('accounts') && results.get('accounts').size > 0) { count += results.get('accounts').size; accounts = ( @@ -48,7 +100,7 @@ export default class SearchResults extends ImmutablePureComponent { {results.get('hashtags').map(hashtag => ( - #{hashtag} + {hashtag} ))} @@ -58,6 +110,7 @@ export default class SearchResults extends ImmutablePureComponent { return (
+
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js index 16d95d417..7273460e2 100644 --- a/app/javascript/mastodon/features/compose/containers/search_results_container.js +++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js @@ -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); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 19aae0332..d8e9ad9ee 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -101,7 +101,7 @@ export default class Compose extends React.PureComponent { {(multiColumn || isSearchPage) && }
-
+ {!isSearchPage &&
{multiColumn && ( @@ -109,7 +109,7 @@ export default class Compose extends React.PureComponent {
)} -
+
} {({ x }) => ( diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 3d9a6a132..019c1f466 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -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); diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js new file mode 100644 index 000000000..95cf8f284 --- /dev/null +++ b/app/javascript/mastodon/reducers/trends.js @@ -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; + } +}; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 2724454fb..c66bc427c 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3334,9 +3334,15 @@ a.status-card { 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 { @@ -5209,3 +5215,76 @@ 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; + + &:hover, + &:focus, + &:active { + span { + text-decoration: underline; + } + } + } + } + + &__current { + width: 100px; + font-size: 24px; + line-height: 36px; + font-weight: 500; + text-align: center; + color: $secondary-text-color; + } + + &__sparkline { + width: 50px; + + path { + stroke: lighten($highlight-text-color, 6%) !important; + } + } + } +} diff --git a/app/models/tag.rb b/app/models/tag.rb index 8b1b02412..4f31f796e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -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) + '%' diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb new file mode 100644 index 000000000..eedd92644 --- /dev/null +++ b/app/models/trending_tags.rb @@ -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 diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb new file mode 100644 index 000000000..74aa571a4 --- /dev/null +++ b/app/serializers/rest/tag_serializer.rb @@ -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 diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index 5b45c865f..0695922b8 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -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 -- cgit From dfbadd68374ab5ddcaa75907261bd91da688fc6b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 28 May 2018 02:42:06 +0200 Subject: Replace recursion in status mapStateToProps (#7645) --- app/javascript/mastodon/features/status/index.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 2e53dfa7e..505a88a3f 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -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')); }); } -- cgit From ab36e0ef72a52e5cc184d63cda0fe411c8b4086a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 28 May 2018 05:21:04 +0200 Subject: Record trending tags from ActivityPub, too (#7647) --- app/lib/activitypub/activity/create.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 010ed1bb5..869749f1e 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -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 -- cgit From b87a1229c74aebec02cd08091f25613f6dff4428 Mon Sep 17 00:00:00 2001 From: tateisu Date: Mon, 28 May 2018 18:04:06 +0900 Subject: optimize direct timeline (#7614) * optimize direct timeline * fix typo in class name * change filter condition for direct timeline * fix codestyle issue * revoke index_accounts_not_silenced because direct timeline does not use it. * revoke index_accounts_not_silenced because direct timeline does not use it. * fix rspec test condition. * fix rspec test condition. * fix rspec test condition. * revoke adding column and partial index * (direct timeline) move merging logic to model * fix pagination parameter * add method arguments that switches return array of status or cache_ids * fix order by * returns ActiveRecord.Relation in default behavor * fix codestyle issue --- .../api/v1/timelines/direct_controller.rb | 15 +++++--- app/models/status.rb | 43 +++++++++++++++++++--- spec/models/status_spec.rb | 23 ++++++------ 3 files changed, 59 insertions(+), 22 deletions(-) (limited to 'app') diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb index d455227eb..ef64078be 100644 --- a/app/controllers/api/v1/timelines/direct_controller.rb +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -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 diff --git a/app/models/status.rb b/app/models/status.rb index 853e75b43..54f3f68f5 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -188,12 +188,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 + + # _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 - apply_timeline_filters(query, account, false) + 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) diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index c6701018e..aee4f49b4 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do describe '#target' do it 'returns nil if the status is self-contained' do - expect(subject.target).to be_nil + expect(subject.target).to be_nil end it 'returns nil if the status is a reply' do @@ -333,24 +333,25 @@ RSpec.describe Status, type: :model do expect(@results).to_not include(@followed_public_status) end - it 'includes direct statuses mentioning recipient from followed' do - Fabricate(:mention, account: account, status: @followed_direct_status) - expect(@results).to include(@followed_direct_status) - end - it 'does not include direct statuses not mentioning recipient from followed' do expect(@results).to_not include(@followed_direct_status) end - it 'includes direct statuses mentioning recipient from non-followed' do - Fabricate(:mention, account: account, status: @not_followed_direct_status) - expect(@results).to include(@not_followed_direct_status) - end - it 'does not include direct statuses not mentioning recipient from non-followed' do expect(@results).to_not include(@not_followed_direct_status) end + it 'includes direct statuses mentioning recipient from followed' do + Fabricate(:mention, account: account, status: @followed_direct_status) + results2 = Status.as_direct_timeline(account) + expect(results2).to include(@followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from non-followed' do + Fabricate(:mention, account: account, status: @not_followed_direct_status) + results2 = Status.as_direct_timeline(account) + expect(results2).to include(@not_followed_direct_status) + end end describe '.as_public_timeline' do -- cgit From 04a2cf8bcc7ab5878b29ee60af41dd661a6e19eb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 28 May 2018 19:12:53 +0200 Subject: Fix incomplete flex style on trends items (#7655) --- app/javascript/styles/mastodon/components.scss | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'app') diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c66bc427c..a2a18b5a0 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5259,6 +5259,9 @@ noscript { font-size: 14px; font-weight: 500; display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &:hover, &:focus, @@ -5271,6 +5274,7 @@ noscript { } &__current { + flex: 0 0 auto; width: 100px; font-size: 24px; line-height: 36px; @@ -5280,6 +5284,7 @@ noscript { } &__sparkline { + flex: 0 0 auto; width: 50px; path { -- cgit From c0355878ba00808d1d8710cb27b1fbb36bcb54e8 Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Tue, 29 May 2018 02:13:20 +0900 Subject: Fix embed, error and onboarding modals in light theme (#7656) --- app/javascript/styles/mastodon-light/diff.scss | 38 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 2b88b830c..fe304317d 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -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 { -- cgit From 03f4c214b459a241a1aab08095a986475bd6f8f0 Mon Sep 17 00:00:00 2001 From: takayamaki Date: Tue, 29 May 2018 02:14:24 +0900 Subject: fix: Don't validate MX record in development (#7654) --- app/validators/email_mx_validator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb index d4c7cc252..3cc5853c6 100644 --- a/app/validators/email_mx_validator.rb +++ b/app/validators/email_mx_validator.rb @@ -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 -- cgit From d95642f6d913a99fc44f0ac0695d53534afb7962 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Tue, 29 May 2018 07:43:47 +0900 Subject: Cache attachments on external host with service worker (#7493) --- .env.production.sample | 8 ++++++++ .eslintrc.yml | 3 +++ app/javascript/mastodon/service_worker/entry.js | 2 +- config/webpack/production.js | 20 ++++++++++++++++++-- 4 files changed, 30 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/.env.production.sample b/.env.production.sample index 24b6b0143..3047f7595 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -88,6 +88,10 @@ SMTP_FROM_ADDRESS=notifications@example.com # CDN_HOST=https://assets.example.com # S3 (optional) +# The attachment host must allow cross origin request from WEB_DOMAIN or +# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the +# following header field: +# Access-Control-Allow-Origin: https://192.168.1.123:9000/ # S3_ENABLED=true # S3_BUCKET= # AWS_ACCESS_KEY_ID= @@ -97,6 +101,8 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_HOSTNAME=192.168.1.123:9000 # S3 (Minio Config (optional) Please check Minio instance for details) +# The attachment host must allow cross origin request - see the description +# above. # S3_ENABLED=true # S3_BUCKET= # AWS_ACCESS_KEY_ID= @@ -108,6 +114,8 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_SIGNATURE_VERSION= # Swift (optional) +# The attachment host must allow cross origin request - see the description +# above. # SWIFT_ENABLED=true # SWIFT_USERNAME= # For Keystone V3, the value for SWIFT_TENANT should be the project name diff --git a/.eslintrc.yml b/.eslintrc.yml index 576e5b70a..205c9460a 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -7,6 +7,9 @@ env: es6: true jest: true +globals: + ATTACHMENT_HOST: false + parser: babel-eslint plugins: diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js index ce42271a9..c1854c1cd 100644 --- a/app/javascript/mastodon/service_worker/entry.js +++ b/app/javascript/mastodon/service_worker/entry.js @@ -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) { diff --git a/config/webpack/production.js b/config/webpack/production.js index a82330791..408c56930 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -6,8 +6,9 @@ const CompressionPlugin = require('compression-webpack-plugin'); const sharedConfig = require('./shared.js'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const OfflinePlugin = require('offline-plugin'); -const { env, publicPath } = require('./configuration.js'); +const { publicPath } = require('./configuration.js'); const path = require('path'); +const { URL } = require('url'); let compressionAlgorithm; try { @@ -19,6 +20,21 @@ try { compressionAlgorithm = 'gzip'; } +let attachmentHost; + +if (process.env.S3_ENABLED === 'true') { + if (process.env.S3_CLOUDFRONT_HOST) { + attachmentHost = process.env.S3_CLOUDFRONT_HOST; + } else { + attachmentHost = process.env.S3_HOSTNAME || `s3-${process.env.S3_REGION || 'us-east-1'}.amazonaws.com`; + } +} else if (process.env.SWIFT_ENABLED === 'true') { + const { host } = new URL(process.env.SWIFT_OBJECT_URL); + attachmentHost = host; +} else { + attachmentHost = null; +} + module.exports = merge(sharedConfig, { output: { filename: '[name]-[chunkhash].js', @@ -90,7 +106,7 @@ module.exports = merge(sharedConfig, { '**/*.woff', ], ServiceWorker: { - entry: `imports-loader?process.env=>${encodeURIComponent(JSON.stringify(env))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`, + entry: `imports-loader?ATTACHMENT_HOST=>${encodeURIComponent(JSON.stringify(attachmentHost))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`, cacheName: 'mastodon', output: '../assets/sw.js', publicPath: '/sw.js', -- cgit From e599d7caf2642c7143616e8402b7d730d32c349d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 29 May 2018 01:39:02 +0200 Subject: Rescue Mastodon::DimensionsValidationError in Remoteable (#7662) Fix #7660 --- app/models/concerns/remotable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 20ddbca53..c17f04776 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -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 -- cgit From 90b64c006998ec3bae365007781c61e8a79eeeef Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 29 May 2018 02:01:04 +0200 Subject: Always display tab navigation on local/federated timeline even when empty (#7663) Fix #7659 --- app/javascript/mastodon/components/scrollable_list.js | 11 ++++++++--- app/javascript/mastodon/components/status_list.js | 1 + app/javascript/mastodon/features/community_timeline/index.js | 1 + app/javascript/mastodon/features/public_timeline/index.js | 1 + 4 files changed, 11 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index fd6858d05..4b433f32c 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -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 = ( -
- {emptyMessage} +
+ {alwaysPrepend && prepend} + +
+ {emptyMessage} +
); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 0c971ceb0..1c34d0640 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent { hasMore: PropTypes.bool, prepend: PropTypes.node, emptyMessage: PropTypes.node, + alwaysPrepend: PropTypes.bool, }; static defaultProps = { diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index f9ee835bc..d375edbd5 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -127,6 +127,7 @@ export default class CommunityTimeline extends React.PureComponent { Date: Tue, 29 May 2018 02:01:24 +0200 Subject: Add GET /api/v2/search which returns rich tag objects, adjust web UI (#7661) --- app/controllers/api/v2/search_controller.rb | 8 ++++ app/javascript/mastodon/actions/search.js | 2 +- .../features/compose/components/search_results.js | 56 +++++++++++----------- app/javascript/mastodon/reducers/search.js | 4 +- app/javascript/styles/mastodon/components.scss | 44 ++++++++--------- app/serializers/rest/v2/search_serializer.rb | 7 +++ config/routes.rb | 4 ++ 7 files changed, 69 insertions(+), 56 deletions(-) create mode 100644 app/controllers/api/v2/search_controller.rb create mode 100644 app/serializers/rest/v2/search_serializer.rb (limited to 'app') diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb new file mode 100644 index 000000000..2e91d68ee --- /dev/null +++ b/app/controllers/api/v2/search_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 882c1709e..b670d25c3 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -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, diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index f2655c14d..445bf27bb 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -16,6 +16,28 @@ const shortNumberFormat = number => { } }; +const renderHashtag = hashtag => ( +
+
+ + #{hashtag.get('name')} + + + {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))} }} /> +
+ +
+ {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} +
+ +
+ day.get('uses')).toArray()}> + + +
+
+); + export default class SearchResults extends ImmutablePureComponent { static propTypes = { @@ -44,27 +66,7 @@ export default class SearchResults extends ImmutablePureComponent {
- {trends && trends.map(hashtag => ( -
-
- - #{hashtag.get('name')} - - - {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))} }} /> -
- -
- {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} -
- -
- day.get('uses')).toArray()}> - - -
-
- ))} + {trends && trends.map(hashtag => renderHashtag(hashtag))}
); @@ -74,7 +76,7 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('accounts').size; accounts = (
-
+
{results.get('accounts').map(accountId => )}
@@ -85,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('statuses').size; statuses = (
-
+
{results.get('statuses').map(statusId => )}
@@ -96,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('hashtags').size; hashtags = (
-
+
- {results.get('hashtags').map(hashtag => ( - - {hashtag} - - ))} + {results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
); } diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index 56fd7226b..4758defb1 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -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; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a2a18b5a0..c93d8e86a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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,7 +3342,6 @@ 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; font-weight: 500; font-size: 16px; @@ -3346,33 +3354,21 @@ a.status-card { } .search-results__section { - margin-bottom: 20px; + margin-bottom: 5px; h5 { - position: relative; - - &::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%); - } + 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; - 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; } } diff --git a/app/serializers/rest/v2/search_serializer.rb b/app/serializers/rest/v2/search_serializer.rb new file mode 100644 index 000000000..cdb6b3a53 --- /dev/null +++ b/app/serializers/rest/v2/search_serializer.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 2fcb885ed..31e90e2ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -315,6 +315,10 @@ Rails.application.routes.draw do end end + namespace :v2 do + get '/search', to: 'search#index', as: :search + end + namespace :web do resource :settings, only: [:update] resource :embed, only: [:create] -- cgit From 90908fc24ba57c877de75fe117b8cc234e29d4f0 Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 29 May 2018 20:34:02 +0900 Subject: Fix N+1 on AtomSerializer (#7669) --- app/lib/ostatus/atom_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 698f2ee22..5c6ff4f9b 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -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 -- cgit