diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
new file mode 100644
index 0000000000..3f22565664
--- /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 dcd41b35c1..e77f57910b 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
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 0000000000..265fd5fab2
--- /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 5b3bfd71fe..20a55785c5 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -29,7 +29,7 @@ class Settings::ProfilesController < ApplicationController
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 9a663051c6..8807cc7846 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 06effbdb2e..63a5c61b8b 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 f843f0b42b..a838ca778b 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 fb089de90e..20b0b72391 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 @@ class Account < ApplicationRecord
@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 d5715268eb..9813aa84ff 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 0000000000..3c36c155ab
--- /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 0f7482fa6a..ae50860eda 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 fa3ec9a3da..3581df8dd8 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 4f31f796e6..b28e2cc185 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 @@ class Tag < ApplicationRecord
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 0000000000..c63de01dbe
--- /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 ec69d944a3..36665177db 100644
--- a/app/services/update_account_service.rb
+++ b/app/services/update_account_service.rb
@@ -10,6 +10,7 @@ class UpdateAccountService < BaseService
authorize_all_follow_requests(account) if was_locked && !account.locked
check_links(account)
+ process_hashtags(account)
end
end
@@ -24,4 +25,8 @@ class UpdateAccountService < BaseService
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 0000000000..961b83f93c
--- /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 0000000000..4ba3958605
--- /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 0000000000..7cd6b50d43
--- /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 e17c777d05..831c7f012e 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 4530ffae27..fa3869f6f9 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 2d27a4ac7c..243b513fd6 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 d34ec79cc6..e24d8f4e6a 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 99d227f111..1b3c05ef7a 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -28,6 +28,7 @@ SimpleNavigation::Configuration.run do |navigation|
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 b203e13294..2628684137 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -80,6 +80,11 @@ Rails.application.routes.draw do
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 @@ Rails.application.routes.draw do
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 0000000000..3c275c2b78
--- /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 0000000000..5bbae2203e
--- /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 0000000000..9466627071
--- /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 0000000000..15ed8587e3
--- /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 d675214425..6d643c27ca 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 @@ ActiveRecord::Schema.define(version: 2018_11_27_130500) do
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 @@ ActiveRecord::Schema.define(version: 2018_11_27_130500) do
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 @@ ActiveRecord::Schema.define(version: 2018_11_27_130500) do
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 @@ ActiveRecord::Schema.define(version: 2018_11_27_130500) do
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 0000000000..9edb550bec
--- /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 0000000000..f1ebc55783
--- /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