diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb new file mode 100644 index 0000000000000..3f22565664153 --- /dev/null +++ b/app/controllers/admin/tags_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Admin + class TagsController < BaseController + before_action :set_tags, only: :index + before_action :set_tag, except: :index + before_action :set_filter_params + + def index + authorize :tag, :index? + end + + def hide + authorize @tag, :hide? + @tag.account_tag_stat.update!(hidden: true) + redirect_to admin_tags_path(@filter_params) + end + + def unhide + authorize @tag, :unhide? + @tag.account_tag_stat.update!(hidden: true) + redirect_to admin_tags_path(@filter_params) + end + + private + + def set_tags + @tags = Tag.discoverable + @tags.merge!(Tag.hidden) if filter_params[:hidden] + end + + def set_tag + @tag = Tag.find(params[:id]) + end + + def set_filter_params + @filter_params = filter_params.to_hash.symbolize_keys + end + + def filter_params + params.permit(:hidden) + end + end +end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index dcd41b35c1b37..e77f57910b2bf 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -21,7 +21,7 @@ def update private def account_params - params.permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value]) + params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) end def user_settings_params diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb new file mode 100644 index 0000000000000..265fd5fab28c1 --- /dev/null +++ b/app/controllers/directories_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class DirectoriesController < ApplicationController + layout 'public' + + before_action :set_instance_presenter + before_action :set_tag, only: :show + before_action :set_tags + before_action :set_accounts + + def index + render :index + end + + def show + render :index + end + + private + + def set_tag + @tag = Tag.discoverable.find_by!(name: params[:id].downcase) + end + + def set_tags + @tags = Tag.discoverable.limit(30) + end + + def set_accounts + @accounts = Account.searchable.discoverable.page(params[:page]).per(50).tap do |query| + query.merge!(Account.tagged_with(@tag.id)) if @tag + + if popular_requested? + query.merge!(Account.popular) + else + query.merge!(Account.by_recent_status) + end + end + end + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + + def popular_requested? + request.path.ends_with?('/popular') + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 5b3bfd71fe322..20a55785c51ba 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -29,7 +29,7 @@ def update private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) end def set_account diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 9a663051c632d..8807cc784681c 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -5,8 +5,9 @@ module Admin::FilterHelper REPORT_FILTERS = %i(resolved account_id target_account_id).freeze INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze + TAGS_FILTERS = %i(hidden).freeze - FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 06effbdb2eda4..63a5c61b8bfcd 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -189,6 +189,11 @@ &--under-tabs { border-radius: 0 0 4px 4px; } + + &--flexible { + box-sizing: border-box; + min-height: 100%; + } } .account-role { diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index f843f0b42b54b..a838ca778b2ce 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -240,3 +240,168 @@ border-radius: 0; } } + +.page-header { + background: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + padding: 60px 15px; + text-align: center; + margin: 10px 0; + + h1 { + color: $primary-text-color; + font-size: 36px; + line-height: 1.1; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 15px; + color: $darker-text-color; + } +} + +.directory { + background: $ui-base-color; + border-radius: 0 0 4px 4px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &__tag { + box-sizing: border-box; + margin-bottom: 10px; + + a { + display: flex; + align-items: center; + justify-content: space-between; + background: $ui-base-color; + border-radius: 4px; + padding: 15px; + text-decoration: none; + color: inherit; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 8%); + } + } + + &.active a { + background: $ui-highlight-color; + cursor: default; + } + + h4 { + flex: 1 1 auto; + font-size: 18px; + font-weight: 700; + color: $primary-text-color; + + .fa { + color: $darker-text-color; + } + + small { + display: block; + font-weight: 400; + font-size: 15px; + margin-top: 8px; + color: $darker-text-color; + } + } + + &.active h4 { + &, + .fa, + small { + color: $primary-text-color; + } + } + + .avatar-stack { + flex: 0 0 auto; + width: (36px + 4px) * 3; + } + + &.active .avatar-stack .account__avatar { + border-color: $ui-highlight-color; + } + } +} + +.avatar-stack { + display: flex; + justify-content: flex-end; + + .account__avatar { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 50%; + position: relative; + margin-left: -10px; + border: 2px solid $ui-base-color; + + &:nth-child(1) { + z-index: 1; + } + + &:nth-child(2) { + z-index: 2; + } + + &:nth-child(3) { + z-index: 3; + } + } +} + +.accounts-table { + width: 100%; + + .account { + padding: 0; + border: 0; + } + + thead th { + text-align: center; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 700; + padding: 10px; + + &:first-child { + text-align: left; + } + } + + tbody td { + padding: 15px 0; + vertical-align: middle; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + tbody tr:last-child td { + border-bottom: 0; + } + + &__count { + width: 120px; + text-align: center; + font-size: 15px; + font-weight: 500; + color: $primary-text-color; + + small { + display: block; + color: $darker-text-color; + font-weight: 400; + font-size: 14px; + } + } +} diff --git a/app/models/account.rb b/app/models/account.rb index fb089de90ee16..20b0b72391a44 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -43,11 +43,13 @@ # featured_collection_url :string # fields :jsonb # actor_type :string +# discoverable :boolean # class Account < ApplicationRecord USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i + MIN_FOLLOWERS_DISCOVERY = 10 include AccountAssociations include AccountAvatar @@ -89,6 +91,10 @@ class Account < ApplicationRecord scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) } + scope :discoverable, -> { where(silenced: false).where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) } + scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) } + scope :popular, -> { order('account_stats.followers_count desc') } + scope :by_recent_status, -> { order('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc') } delegate :email, :unconfirmed_email, @@ -174,6 +180,40 @@ def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end + def tags_as_strings=(tag_names) + tag_names.map! { |name| name.mb_chars.downcase } + tag_names.uniq!(&:to_s) + + # Existing hashtags + hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag } + + # Initialize not yet existing hashtags + tag_names.each do |name| + next if hashtags_map.key?(name) + hashtags_map[name.downcase] = Tag.new(name: name) + end + + # Remove hashtags that are to be deleted + tags.each do |tag| + if hashtags_map.key?(tag.name) + hashtags_map.delete(tag.name) + else + transaction do + tags.delete(tag) + tag.decrement_count!(:accounts_count) + end + end + end + + # Add hashtags that were so far missing + hashtags_map.each_value do |tag| + transaction do + tags << tag + tag.increment_count!(:accounts_count) + end + end + end + def fields (self[:fields] || []).map { |f| Field.new(self, f) } end diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb index d5715268eb20a..9813aa84ff105 100644 --- a/app/models/account_stat.rb +++ b/app/models/account_stat.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - # == Schema Information # # Table name: account_stats @@ -11,16 +10,25 @@ # followers_count :bigint(8) default(0), not null # created_at :datetime not null # updated_at :datetime not null +# last_status_at :datetime # class AccountStat < ApplicationRecord belongs_to :account, inverse_of: :account_stat def increment_count!(key) - update(key => public_send(key) + 1) + update(attributes_for_increment(key)) end def decrement_count!(key) update(key => [public_send(key) - 1, 0].max) end + + private + + def attributes_for_increment(key) + attrs = { key => public_send(key) + 1 } + attrs[:last_status_at] = Time.now.utc if key == :statuses_count + attrs + end end diff --git a/app/models/account_tag_stat.rb b/app/models/account_tag_stat.rb new file mode 100644 index 0000000000000..3c36c155abede --- /dev/null +++ b/app/models/account_tag_stat.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_tag_stats +# +# id :bigint(8) not null, primary key +# tag_id :bigint(8) not null +# accounts_count :bigint(8) default(0), not null +# hidden :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountTagStat < ApplicationRecord + belongs_to :tag, inverse_of: :account_tag_stat + + def increment_count!(key) + update(key => public_send(key) + 1) + end + + def decrement_count!(key) + update(key => [public_send(key) - 1, 0].max) + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 0f7482fa6a3b4..ae50860eda39a 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -49,5 +49,8 @@ module AccountAssociations # Account migrations belongs_to :moved_to_account, class_name: 'Account', optional: true + + # Hashtags + has_and_belongs_to_many :tags end end diff --git a/app/models/concerns/account_counters.rb b/app/models/concerns/account_counters.rb index fa3ec9a3da075..3581df8dd8b2a 100644 --- a/app/models/concerns/account_counters.rb +++ b/app/models/concerns/account_counters.rb @@ -16,6 +16,7 @@ module AccountCounters :followers_count=, :increment_count!, :decrement_count!, + :last_status_at, to: :account_stat def account_stat diff --git a/app/models/tag.rb b/app/models/tag.rb index 4f31f796e6ab1..b28e2cc18512a 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -11,12 +11,31 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses + has_and_belongs_to_many :accounts + + has_one :account_tag_stat, dependent: :destroy HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_ยท][[:word:]_]*' HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, uniqueness: 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(name: :asc) } + scope :hidden, -> { where(account_tag_stats: { hidden: true }) } + + delegate :accounts_count, + :accounts_count=, + :increment_count!, + :decrement_count!, + :hidden?, + to: :account_tag_stat + + after_save :save_account_tag_stat + + def account_tag_stat + super || build_account_tag_stat + end + def to_param name end @@ -43,4 +62,11 @@ def search_for(term, limit = 5) Tag.where('lower(name) like lower(?)', pattern).order(:name).limit(limit) end end + + private + + def save_account_tag_stat + return unless account_tag_stat&.changed? + account_tag_stat.save + end end diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb new file mode 100644 index 0000000000000..c63de01dbe9ef --- /dev/null +++ b/app/policies/tag_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TagPolicy < ApplicationPolicy + def index? + staff? + end + + def hide? + staff? + end + + def unhide? + staff? + end +end diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb index ec69d944a3dd4..36665177db9a2 100644 --- a/app/services/update_account_service.rb +++ b/app/services/update_account_service.rb @@ -10,6 +10,7 @@ def call(account, params, raise_error: false) authorize_all_follow_requests(account) if was_locked && !account.locked check_links(account) + process_hashtags(account) end end @@ -24,4 +25,8 @@ def authorize_all_follow_requests(account) def check_links(account) VerifyAccountLinksWorker.perform_async(account.id) end + + def process_hashtags(account) + account.tags_as_strings = Extractor.extract_hashtags(account.note) + end end diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml new file mode 100644 index 0000000000000..961b83f93c23c --- /dev/null +++ b/app/views/admin/tags/_tag.html.haml @@ -0,0 +1,12 @@ +%tr + %td + = link_to explore_hashtag_path(tag) do + = fa_icon 'hashtag' + = tag.name + %td + = t('directories.people', count: tag.accounts_count) + %td + - if tag.hidden? + = table_link_to 'eye', t('admin.tags.unhide'), unhide_admin_tag_path(tag.id, **@filter_params), method: :post + - else + = table_link_to 'eye-slash', t('admin.tags.hide'), hide_admin_tag_path(tag.id, **@filter_params), method: :post diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml new file mode 100644 index 0000000000000..4ba3958605d69 --- /dev/null +++ b/app/views/admin/tags/index.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('admin.tags.title') + +.filters + .filter-subset + %strong= t('admin.reports.status') + %ul + %li= filter_link_to t('admin.tags.visible'), hidden: nil + %li= filter_link_to t('admin.tags.hidden'), hidden: '1' + +.table-wrapper + %table.table + %thead + %tr + %th= t('admin.tags.name') + %th= t('admin.tags.accounts') + %th + %tbody + = render @tags diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml new file mode 100644 index 0000000000000..7cd6b50d436ba --- /dev/null +++ b/app/views/directories/index.html.haml @@ -0,0 +1,59 @@ +- content_for :page_title do + = t('directories.explore_mastodon') + +- content_for :header_tags do + %meta{ name: 'description', content: t('directories.explanation') } + + = opengraph 'og:site_name', site_title + = opengraph 'og:title', t('directories.explore_mastodon', title: site_title) + = opengraph 'og:description', t('directories.explanation') + +.page-header + %h1= t('directories.explore_mastodon', title: site_title) + %p= t('directories.explanation') + +.grid + .column-0 + .account__section-headline + = active_link_to t('directories.most_recently_active'), @tag ? explore_hashtag_path(@tag) : explore_path + = active_link_to t('directories.most_popular'), @tag ? explore_hashtag_popular_path(@tag) : explore_popular_path + + - if @accounts.empty? + = nothing_here + - else + .directory + %table.accounts-table + %tbody + - @accounts.each do |account| + %tr + %td= account_link_to account + %td.accounts-table__count + = number_to_human account.statuses_count, strip_insignificant_zeros: true + %small= t('accounts.posts', count: account.statuses_count) + %td.accounts-table__count + = number_to_human account.followers_count, strip_insignificant_zeros: true + %small= t('accounts.followers', count: account.followers_count) + %td.accounts-table__count + - if account.last_status_at.present? + %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at + - else + \- + %small= t('accounts.last_active') + + = paginate @accounts + + .column-1 + - if @tags.empty? + .nothing-here.nothing-here--flexible + - else + - @tags.each do |tag| + .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil } + = link_to explore_hashtag_path(tag) do + %h4 + = fa_icon 'hashtag' + = tag.name + %small= t('directories.people', count: tag.accounts_count) + + .avatar-stack + - tag.accounts.limit(3).each do |account| + = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar' diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index e17c777d05212..831c7f012eaf2 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -8,6 +8,10 @@ .nav-left = link_to root_url, class: 'brand' do = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' + + = link_to t('directories.directory'), explore_path, class: 'nav-link' + = link_to t('about.about_this'), about_more_path, class: 'nav-link' + = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link' .nav-center .nav-right - if user_signed_in? diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 4530ffae277c5..fa3869f6f988b 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -18,7 +18,6 @@ = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT)) - %hr.spacer/ .fields-group @@ -27,6 +26,9 @@ .fields-group = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot') + .fields-group + = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path) + %hr.spacer/ .fields-row diff --git a/config/locales/en.yml b/config/locales/en.yml index 2d27a4ac7c051..243b513fd653d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -48,6 +48,7 @@ en: other: Followers following: Following joined: Joined %{date} + last_active: last active link_verified_on: Ownership of this link was checked on %{date} media: Media moved_html: "%{name} has moved to %{new_profile_link}:" @@ -114,6 +115,7 @@ en: media_attachments: Media attachments memorialize: Turn into memoriam moderation: + active: Active all: All silenced: Silenced suspended: Suspended @@ -439,6 +441,14 @@ en: proceed: Proceed title: Suspend %{acct} warning_html: 'Suspending this account will irreversibly delete data from this account, which includes:' + tags: + accounts: Accounts + hidden: Hidden + hide: Hide from directory + name: Hashtag + title: Hashtags + unhide: Show in directory + visible: Visible title: Administration admin_mailer: new_report: @@ -517,6 +527,15 @@ en: success_msg: Your account was successfully deleted warning_html: Only deletion of content from this particular instance is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases. warning_title: Disseminated content availability + directories: + directory: Profile directory + explanation: Discover users based on their interests + explore_mastodon: Explore %{title} + most_popular: Most popular + most_recently_active: Most recently active + people: + one: "%{count} person" + other: "%{count} people" errors: '403': You don't have permission to view this page. '404': The page you were looking for doesn't exist. diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index d34ec79cc6bb9..e24d8f4e6ac43 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -8,6 +8,7 @@ en: bot: This account mainly performs automated actions and might not be monitored context: One or multiple contexts where the filter should apply digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence + discoverable_html: The directory lets people find accounts based on interests and activity. Requires at least %{min_followers} followers email: You will be sent a confirmation e-mail fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px @@ -48,6 +49,7 @@ en: context: Filter contexts current_password: Current password data: Data + discoverable: List this account on the directory display_name: Display name email: E-mail address expires_in: Expire after diff --git a/config/navigation.rb b/config/navigation.rb index 99d227f1112db..1b3c05ef7a8fa 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -28,6 +28,7 @@ admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} admin.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path + admin.item :tags, safe_join([fa_icon('tag fw'), t('admin.tags.title')]), admin_tags_path admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? } admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? } admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } diff --git a/config/routes.rb b/config/routes.rb index b203e13294008..2628684137129 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,6 +80,11 @@ get '/interact/:id', to: 'remote_interaction#new', as: :remote_interaction post '/interact/:id', to: 'remote_interaction#create' + get '/explore', to: 'directories#index', as: :explore + get '/explore/popular', to: 'directories#index', as: :explore_popular + get '/explore/:id', to: 'directories#show', as: :explore_hashtag + get '/explore/:id/popular', to: 'directories#show', as: :explore_hashtag_popular + namespace :settings do resource :profile, only: [:show, :update] resource :preferences, only: [:show, :update] @@ -207,6 +212,13 @@ end resources :account_moderation_notes, only: [:create, :destroy] + + resources :tags, only: [:index] do + member do + post :hide + post :unhide + end + end end get '/admin', to: redirect('/admin/dashboard', status: 302) diff --git a/db/migrate/20181203003808_create_accounts_tags_join_table.rb b/db/migrate/20181203003808_create_accounts_tags_join_table.rb new file mode 100644 index 0000000000000..3c275c2b785c2 --- /dev/null +++ b/db/migrate/20181203003808_create_accounts_tags_join_table.rb @@ -0,0 +1,8 @@ +class CreateAccountsTagsJoinTable < ActiveRecord::Migration[5.2] + def change + create_join_table :accounts, :tags do |t| + t.index [:account_id, :tag_id] + t.index [:tag_id, :account_id], unique: true + end + end +end diff --git a/db/migrate/20181203021853_add_discoverable_to_accounts.rb b/db/migrate/20181203021853_add_discoverable_to_accounts.rb new file mode 100644 index 0000000000000..5bbae2203e086 --- /dev/null +++ b/db/migrate/20181203021853_add_discoverable_to_accounts.rb @@ -0,0 +1,5 @@ +class AddDiscoverableToAccounts < ActiveRecord::Migration[5.2] + def change + add_column :accounts, :discoverable, :boolean + end +end diff --git a/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb b/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb new file mode 100644 index 0000000000000..9466627071503 --- /dev/null +++ b/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb @@ -0,0 +1,5 @@ +class AddLastStatusAtToAccountStats < ActiveRecord::Migration[5.2] + def change + add_column :account_stats, :last_status_at, :datetime + end +end diff --git a/db/migrate/20181204215309_create_account_tag_stats.rb b/db/migrate/20181204215309_create_account_tag_stats.rb new file mode 100644 index 0000000000000..15ed8587e39d2 --- /dev/null +++ b/db/migrate/20181204215309_create_account_tag_stats.rb @@ -0,0 +1,11 @@ +class CreateAccountTagStats < ActiveRecord::Migration[5.2] + def change + create_table :account_tag_stats do |t| + t.belongs_to :tag, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true } + t.bigint :accounts_count, default: 0, null: false + t.boolean :hidden, default: false, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d67521442552b..6d643c27cad43 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_11_27_130500) do +ActiveRecord::Schema.define(version: 2018_12_04_215309) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -63,9 +63,19 @@ t.bigint "followers_count", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "last_status_at" t.index ["account_id"], name: "index_account_stats_on_account_id", unique: true end + create_table "account_tag_stats", force: :cascade do |t| + t.bigint "tag_id", null: false + t.bigint "accounts_count", default: 0, null: false + t.boolean "hidden", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_account_tag_stats_on_tag_id", unique: true + end + create_table "accounts", force: :cascade do |t| t.string "username", default: "", null: false t.string "domain" @@ -106,6 +116,7 @@ t.string "featured_collection_url" t.jsonb "fields" t.string "actor_type" + t.boolean "discoverable" t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id" @@ -113,6 +124,13 @@ t.index ["url"], name: "index_accounts_on_url" end + create_table "accounts_tags", id: false, force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "tag_id", null: false + t.index ["account_id", "tag_id"], name: "index_accounts_tags_on_account_id_and_tag_id" + t.index ["tag_id", "account_id"], name: "index_accounts_tags_on_tag_id_and_account_id", unique: true + end + create_table "admin_action_logs", force: :cascade do |t| t.bigint "account_id" t.string "action", default: "", null: false @@ -637,6 +655,7 @@ add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "account_pins", "accounts", on_delete: :cascade add_foreign_key "account_stats", "accounts", on_delete: :cascade + add_foreign_key "account_tag_stats", "tags", on_delete: :cascade add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade add_foreign_key "backups", "users", on_delete: :nullify diff --git a/spec/fabricators/account_tag_stat_fabricator.rb b/spec/fabricators/account_tag_stat_fabricator.rb new file mode 100644 index 0000000000000..9edb550beca21 --- /dev/null +++ b/spec/fabricators/account_tag_stat_fabricator.rb @@ -0,0 +1,3 @@ +Fabricator(:account_tag_stat) do + accounts_count "" +end diff --git a/spec/models/account_tag_stat_spec.rb b/spec/models/account_tag_stat_spec.rb new file mode 100644 index 0000000000000..f1ebc557834fa --- /dev/null +++ b/spec/models/account_tag_stat_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AccountTagStat, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end