Browse Source

Add featured hashtags to profiles (#9755)

* Add hashtag filter to profiles

GET /@:username/tagged/:hashtag
GET /api/v1/accounts/:id/statuses?tagged=:hashtag

* Display featured hashtags on public profile

* Use separate model for featured tags

* Update featured hashtag counters on-write

* Limit featured tags to 10
Eugen Rochko 2 weeks ago
parent
commit
364f2ff9aa
No account linked to committer's email address

+ 12
- 2
app/controllers/accounts_controller.rb View File

@@ -57,6 +57,7 @@ class AccountsController < ApplicationController
57 57
 
58 58
   def filtered_statuses
59 59
     default_statuses.tap do |statuses|
60
+      statuses.merge!(hashtag_scope)    if tag_requested?
60 61
       statuses.merge!(only_media_scope) if media_requested?
61 62
       statuses.merge!(no_replies_scope) unless replies_requested?
62 63
     end
@@ -78,12 +79,15 @@ class AccountsController < ApplicationController
78 79
     Status.without_replies
79 80
   end
80 81
 
82
+  def hashtag_scope
83
+    Status.tagged_with(Tag.find_by(name: params[:tag].downcase)&.id)
84
+  end
85
+
81 86
   def set_account
82 87
     @account = Account.find_local!(params[:username])
83 88
   end
84 89
 
85 90
   def older_url
86
-    ::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}")
87 91
     pagination_url(max_id: @statuses.last.id)
88 92
   end
89 93
 
@@ -92,7 +96,9 @@ class AccountsController < ApplicationController
92 96
   end
93 97
 
94 98
   def pagination_url(max_id: nil, min_id: nil)
95
-    if media_requested?
99
+    if tag_requested?
100
+      short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
101
+    elsif media_requested?
96 102
       short_account_media_url(@account, max_id: max_id, min_id: min_id)
97 103
     elsif replies_requested?
98 104
       short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
@@ -109,6 +115,10 @@ class AccountsController < ApplicationController
109 115
     request.path.ends_with?('/with_replies')
110 116
   end
111 117
 
118
+  def tag_requested?
119
+    request.path.ends_with?("/tagged/#{params[:tag]}")
120
+  end
121
+
112 122
   def filtered_status_page(params)
113 123
     if params[:min_id].present?
114 124
       filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse

+ 5
- 0
app/controllers/api/v1/accounts/statuses_controller.rb View File

@@ -33,6 +33,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
33 33
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
34 34
     statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
35 35
     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
36
+    statuses.merge!(hashtag_scope)    if params[:tagged].present?
36 37
 
37 38
     statuses
38 39
   end
@@ -67,6 +68,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
67 68
     Status.without_reblogs
68 69
   end
69 70
 
71
+  def hashtag_scope
72
+    Status.tagged_with(Tag.find_by(name: params[:tagged])&.id)
73
+  end
74
+
70 75
   def pagination_params(core_params)
71 76
     params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
72 77
   end

+ 51
- 0
app/controllers/settings/featured_tags_controller.rb View File

@@ -0,0 +1,51 @@
1
+# frozen_string_literal: true
2
+
3
+class Settings::FeaturedTagsController < Settings::BaseController
4
+  layout 'admin'
5
+
6
+  before_action :authenticate_user!
7
+  before_action :set_featured_tags, only: :index
8
+  before_action :set_featured_tag, except: [:index, :create]
9
+  before_action :set_most_used_tags, only: :index
10
+
11
+  def index
12
+    @featured_tag = FeaturedTag.new
13
+  end
14
+
15
+  def create
16
+    @featured_tag = current_account.featured_tags.new(featured_tag_params)
17
+    @featured_tag.reset_data
18
+
19
+    if @featured_tag.save
20
+      redirect_to settings_featured_tags_path
21
+    else
22
+      set_featured_tags
23
+      set_most_used_tags
24
+
25
+      render :index
26
+    end
27
+  end
28
+
29
+  def destroy
30
+    @featured_tag.destroy!
31
+    redirect_to settings_featured_tags_path
32
+  end
33
+
34
+  private
35
+
36
+  def set_featured_tag
37
+    @featured_tag = current_account.featured_tags.find(params[:id])
38
+  end
39
+
40
+  def set_featured_tags
41
+    @featured_tags = current_account.featured_tags.reject(&:new_record?)
42
+  end
43
+
44
+  def set_most_used_tags
45
+    @most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10)
46
+  end
47
+
48
+  def featured_tag_params
49
+    params.require(:featured_tag).permit(:name)
50
+  end
51
+end

+ 1
- 1
app/controllers/settings/profiles_controller.rb View File

@@ -32,6 +32,6 @@ class Settings::ProfilesController < Settings::BaseController
32 32
   end
33 33
 
34 34
   def set_account
35
-    @account = current_user.account
35
+    @account = current_account
36 36
   end
37 37
 end

+ 1
- 0
app/controllers/settings/sessions_controller.rb View File

@@ -1,6 +1,7 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class Settings::SessionsController < Settings::BaseController
4
+  before_action :authenticate_user!
4 5
   before_action :set_session, only: :destroy
5 6
 
6 7
   def destroy

+ 4
- 0
app/javascript/styles/mastodon/accounts.scss View File

@@ -288,3 +288,7 @@
288 288
     border-bottom: 0;
289 289
   }
290 290
 }
291
+
292
+.directory__tag .trends__item__current {
293
+  width: auto;
294
+}

+ 6
- 1
app/javascript/styles/mastodon/admin.scss View File

@@ -153,10 +153,15 @@ $content-width: 840px;
153 153
       font-weight: 500;
154 154
     }
155 155
 
156
-    .directory__tag a {
156
+    .directory__tag > a,
157
+    .directory__tag > div {
157 158
       box-shadow: none;
158 159
     }
159 160
 
161
+    .directory__tag .table-action-link .fa {
162
+      color: inherit;
163
+    }
164
+
160 165
     .directory__tag h4 {
161 166
       font-size: 18px;
162 167
       font-weight: 700;

+ 5
- 2
app/javascript/styles/mastodon/widgets.scss View File

@@ -269,7 +269,8 @@
269 269
     box-sizing: border-box;
270 270
     margin-bottom: 10px;
271 271
 
272
-    a {
272
+    & > a,
273
+    & > div {
273 274
       display: flex;
274 275
       align-items: center;
275 276
       justify-content: space-between;
@@ -279,7 +280,9 @@
279 280
       text-decoration: none;
280 281
       color: inherit;
281 282
       box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
283
+    }
282 284
 
285
+    & > a {
283 286
       &:hover,
284 287
       &:active,
285 288
       &:focus {
@@ -287,7 +290,7 @@
287 290
       }
288 291
     }
289 292
 
290
-    &.active a {
293
+    &.active > a {
291 294
       background: $ui-highlight-color;
292 295
       cursor: default;
293 296
     }

+ 1
- 0
app/models/concerns/account_associations.rb View File

@@ -55,5 +55,6 @@ module AccountAssociations
55 55
 
56 56
     # Hashtags
57 57
     has_and_belongs_to_many :tags
58
+    has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
58 59
   end
59 60
 end

+ 46
- 0
app/models/featured_tag.rb View File

@@ -0,0 +1,46 @@
1
+# frozen_string_literal: true
2
+# == Schema Information
3
+#
4
+# Table name: featured_tags
5
+#
6
+#  id             :bigint(8)        not null, primary key
7
+#  account_id     :bigint(8)
8
+#  tag_id         :bigint(8)
9
+#  statuses_count :bigint(8)        default(0), not null
10
+#  last_status_at :datetime
11
+#  created_at     :datetime         not null
12
+#  updated_at     :datetime         not null
13
+#
14
+
15
+class FeaturedTag < ApplicationRecord
16
+  belongs_to :account, inverse_of: :featured_tags, required: true
17
+  belongs_to :tag, inverse_of: :featured_tags, required: true
18
+
19
+  delegate :name, to: :tag, allow_nil: true
20
+
21
+  validates :name, presence: true
22
+  validate :validate_featured_tags_limit, on: :create
23
+
24
+  def name=(str)
25
+    self.tag = Tag.find_or_initialize_by(name: str.delete('#').mb_chars.downcase.to_s)
26
+  end
27
+
28
+  def increment(timestamp)
29
+    update(statuses_count: statuses_count + 1, last_status_at: timestamp)
30
+  end
31
+
32
+  def decrement(deleted_status_id)
33
+    update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
34
+  end
35
+
36
+  def reset_data
37
+    self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
38
+    self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
39
+  end
40
+
41
+  private
42
+
43
+  def validate_featured_tags_limit
44
+    errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10
45
+  end
46
+end

+ 2
- 0
app/models/tag.rb View File

@@ -14,6 +14,7 @@ class Tag < ApplicationRecord
14 14
   has_and_belongs_to_many :accounts
15 15
   has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
16 16
 
17
+  has_many :featured_tags, dependent: :destroy, inverse_of: :tag
17 18
   has_one :account_tag_stat, dependent: :destroy
18 19
 
19 20
   HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
@@ -23,6 +24,7 @@ class Tag < ApplicationRecord
23 24
 
24 25
   scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
25 26
   scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
27
+  scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
26 28
 
27 29
   delegate :accounts_count,
28 30
            :accounts_count=,

+ 11
- 1
app/services/process_hashtags_service.rb View File

@@ -2,12 +2,22 @@
2 2
 
3 3
 class ProcessHashtagsService < BaseService
4 4
   def call(status, tags = [])
5
-    tags = Extractor.extract_hashtags(status.text) if status.local?
5
+    tags    = Extractor.extract_hashtags(status.text) if status.local?
6
+    records = []
6 7
 
7 8
     tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
8 9
       tag = Tag.where(name: name).first_or_create(name: name)
10
+
9 11
       status.tags << tag
12
+      records << tag
13
+
10 14
       TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
11 15
     end
16
+
17
+    return unless status.public_visibility? || status.unlisted_visibility?
18
+
19
+    status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
20
+      featured_tag.increment(status.created_at)
21
+    end
12 22
   end
13 23
 end

+ 4
- 0
app/services/remove_status_service.rb View File

@@ -131,6 +131,10 @@ class RemoveStatusService < BaseService
131 131
   end
132 132
 
133 133
   def remove_from_hashtags
134
+    @account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
135
+      featured_tag.decrement(@status.id)
136
+    end
137
+
134 138
     return unless @status.public_visibility?
135 139
 
136 140
     @tags.each do |hashtag|

+ 13
- 0
app/views/accounts/show.html.haml View File

@@ -63,4 +63,17 @@
63 63
         - @endorsed_accounts.each do |account|
64 64
           = account_link_to account
65 65
 
66
+    - @account.featured_tags.each do |featured_tag|
67
+      .directory__tag{ class: params[:tag] == featured_tag.name ? 'active' : nil }
68
+        = link_to short_account_tag_path(@account, featured_tag.tag) do
69
+          %h4
70
+            = fa_icon 'hashtag'
71
+            = featured_tag.name
72
+            %small
73
+              - if featured_tag.last_status_at.nil?
74
+                = t('accounts.nothing_here')
75
+              - else
76
+                %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
77
+          .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
78
+
66 79
     = render 'application/sidebar'

+ 27
- 0
app/views/settings/featured_tags/index.html.haml View File

@@ -0,0 +1,27 @@
1
+- content_for :page_title do
2
+  = t('settings.featured_tags')
3
+
4
+= simple_form_for @featured_tag, url: settings_featured_tags_path do |f|
5
+  = render 'shared/error_messages', object: @featured_tag
6
+
7
+  .fields-group
8
+    = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@most_used_tags.map { |tag| link_to("##{tag.name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ')
9
+
10
+  .actions
11
+    = f.button :button, t('featured_tags.add_new'), type: :submit
12
+
13
+%hr.spacer/
14
+
15
+- @featured_tags.each do |featured_tag|
16
+  .directory__tag{ class: params[:tag] == featured_tag.name ? 'active' : nil }
17
+    %div
18
+      %h4
19
+        = fa_icon 'hashtag'
20
+        = featured_tag.name
21
+        %small
22
+          - if featured_tag.last_status_at.nil?
23
+            = t('accounts.nothing_here')
24
+          - else
25
+            %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
26
+          = table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
27
+      .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true

+ 5
- 0
config/locales/en.yml View File

@@ -588,6 +588,10 @@ en:
588 588
     lists: Lists
589 589
     mutes: You mute
590 590
     storage: Media storage
591
+  featured_tags:
592
+    add_new: Add new
593
+    errors:
594
+      limit: You have already featured the maximum amount of hashtags
591 595
   filters:
592 596
     contexts:
593 597
       home: Home timeline
@@ -807,6 +811,7 @@ en:
807 811
     development: Development
808 812
     edit_profile: Edit profile
809 813
     export: Data export
814
+    featured_tags: Featured hashtags
810 815
     followers: Authorized followers
811 816
     import: Import
812 817
     migrate: Account migration

+ 4
- 0
config/locales/simple_form.en.yml View File

@@ -37,6 +37,8 @@ en:
37 37
         setting_theme: Affects how Mastodon looks when you're logged in from any device.
38 38
         username: Your username will be unique on %{domain}
39 39
         whole_word: When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word
40
+      featured_tag:
41
+        name: 'You might want to use one of these:'
40 42
       imports:
41 43
         data: CSV file exported from another Mastodon instance
42 44
       sessions:
@@ -110,6 +112,8 @@ en:
110 112
         username: Username
111 113
         username_or_email: Username or Email
112 114
         whole_word: Whole word
115
+      featured_tag:
116
+        name: Hashtag
113 117
       interactions:
114 118
         must_be_follower: Block notifications from non-followers
115 119
         must_be_following: Block notifications from people you don't follow

+ 1
- 0
config/navigation.rb View File

@@ -6,6 +6,7 @@ SimpleNavigation::Configuration.run do |navigation|
6 6
 
7 7
     primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
8 8
       settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration}
9
+      settings.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url
9 10
       settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
10 11
       settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
11 12
       settings.item :password, safe_join([fa_icon('lock fw'), t('auth.security')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}

+ 2
- 0
config/routes.rb View File

@@ -74,6 +74,7 @@ Rails.application.routes.draw do
74 74
   get '/@:username', to: 'accounts#show', as: :short_account
75 75
   get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
76 76
   get '/@:username/media', to: 'accounts#show', as: :short_account_media
77
+  get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
77 78
   get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
78 79
   get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
79 80
 
@@ -116,6 +117,7 @@ Rails.application.routes.draw do
116 117
     resource :migration, only: [:show, :update]
117 118
 
118 119
     resources :sessions, only: [:destroy]
120
+    resources :featured_tags, only: [:index, :create, :destroy]
119 121
   end
120 122
 
121 123
   resources :media, only: [:show] do

+ 1
- 0
db/migrate/20171005102658_create_account_moderation_notes.rb View File

@@ -7,6 +7,7 @@ class CreateAccountModerationNotes < ActiveRecord::Migration[5.1]
7 7
 
8 8
       t.timestamps
9 9
     end
10
+
10 11
     add_foreign_key :account_moderation_notes, :accounts, column: :target_account_id
11 12
   end
12 13
 end

+ 12
- 0
db/migrate/20190203180359_create_featured_tags.rb View File

@@ -0,0 +1,12 @@
1
+class CreateFeaturedTags < ActiveRecord::Migration[5.2]
2
+  def change
3
+    create_table :featured_tags do |t|
4
+      t.references :account, foreign_key: { on_delete: :cascade }
5
+      t.references :tag, foreign_key: { on_delete: :cascade }
6
+      t.bigint :statuses_count, default: 0, null: false
7
+      t.datetime :last_status_at
8
+
9
+      t.timestamps
10
+    end
11
+  end
12
+end

+ 14
- 1
db/schema.rb View File

@@ -10,7 +10,7 @@
10 10
 #
11 11
 # It's strongly recommended that you check this file into your version control system.
12 12
 
13
-ActiveRecord::Schema.define(version: 2019_02_01_012802) do
13
+ActiveRecord::Schema.define(version: 2019_02_03_180359) do
14 14
 
15 15
   # These are extensions that must be enabled in order to support this database
16 16
   enable_extension "plpgsql"
@@ -250,6 +250,17 @@ ActiveRecord::Schema.define(version: 2019_02_01_012802) do
250 250
     t.index ["status_id"], name: "index_favourites_on_status_id"
251 251
   end
252 252
 
253
+  create_table "featured_tags", force: :cascade do |t|
254
+    t.bigint "account_id"
255
+    t.bigint "tag_id"
256
+    t.bigint "statuses_count", default: 0, null: false
257
+    t.datetime "last_status_at"
258
+    t.datetime "created_at", null: false
259
+    t.datetime "updated_at", null: false
260
+    t.index ["account_id"], name: "index_featured_tags_on_account_id"
261
+    t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
262
+  end
263
+
253 264
   create_table "follow_requests", force: :cascade do |t|
254 265
     t.datetime "created_at", null: false
255 266
     t.datetime "updated_at", null: false
@@ -708,6 +719,8 @@ ActiveRecord::Schema.define(version: 2019_02_01_012802) do
708 719
   add_foreign_key "custom_filters", "accounts", on_delete: :cascade
709 720
   add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
710 721
   add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
722
+  add_foreign_key "featured_tags", "accounts", on_delete: :cascade
723
+  add_foreign_key "featured_tags", "tags", on_delete: :cascade
711 724
   add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
712 725
   add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
713 726
   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade

+ 6
- 0
spec/fabricators/featured_tag_fabricator.rb View File

@@ -0,0 +1,6 @@
1
+Fabricator(:featured_tag) do
2
+  account
3
+  tag
4
+  statuses_count 1_337
5
+  last_status_at Time.now.utc
6
+end

+ 4
- 0
spec/models/featured_tag_spec.rb View File

@@ -0,0 +1,4 @@
1
+require 'rails_helper'
2
+
3
+RSpec.describe FeaturedTag, type: :model do
4
+end

Loading…
Cancel
Save