diff --git a/app/controllers/api/v1/favourite_tags_controller.rb b/app/controllers/api/v1/favourite_tags_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..6d87efc0600c937b4755cdbcd2d976bd2a210d8d --- /dev/null +++ b/app/controllers/api/v1/favourite_tags_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Api::V1::FavouriteTagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:favourite_tags' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:favourite_tags' }, except: [:index, :show] + + before_action :require_user! + before_action :set_favourite_tag, except: [:index, :create] + + def index + @favourite_tags = FavouriteTag.where(account: current_account).all + render json: @favourite_tags, each_serializer: REST::FavouriteTagSerializer + end + + def show + render json: @favourite_tag, serializer: REST::FavouriteTagSerializer + end + + def create + @favourite_tag = FavouriteTag.create!(favourite_tag_params.merge(account: current_account)) + render json: @favourite_tag, serializer: REST::FavouriteTagSerializer + end + + def update + @favourite_tag.update!(favourite_tag_params) + render json: @favourite_tag, serializer: REST::FavouriteTagSerializer + end + + def destroy + @favourite_tag.destroy! + render_empty + end + + private + + def set_favourite_tag + @favourite_tag = FavouriteTag.where(account: current_account).find(params[:id]) + end + + def favourite_tag_params + params.permit(:name) + end +end diff --git a/app/controllers/settings/favourite_tags_controller.rb b/app/controllers/settings/favourite_tags_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..dcf07ade56438b3f37f685bcbe57912591b8ba4a --- /dev/null +++ b/app/controllers/settings/favourite_tags_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Settings::FavouriteTagsController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :set_favourite_tags, only: :index + before_action :set_favourite_tag, except: [:index, :create] + + def index + @favourite_tag = FavouriteTag.new + end + + def create + @favourite_tag = current_account.favourite_tags.new(favourite_tag_params) + + if @favourite_tag.save + redirect_to settings_favourite_tags_path + else + set_favourite_tags + + render :index + end + end + + def destroy + @favourite_tag.destroy! + redirect_to settings_favourite_tags_path + end + + private + + def set_favourite_tag + @favourite_tag = current_account.favourite_tags.find(params[:id]) + end + + def set_favourite_tags + @favourite_tags = current_account.favourite_tags.order(:updated_at).reject(&:new_record?) + end + + def favourite_tag_params + params.require(:favourite_tag).permit(:name) + end +end diff --git a/app/javascript/mastodon/actions/favourite_tags.js b/app/javascript/mastodon/actions/favourite_tags.js new file mode 100644 index 0000000000000000000000000000000000000000..91ffb95eb881b95bea8dd4df9c64c5bbd3258672 --- /dev/null +++ b/app/javascript/mastodon/actions/favourite_tags.js @@ -0,0 +1,59 @@ +import api from '../api'; + +export const FAVOURITE_TAG_FETCH_REQUEST = 'FAVOURITE_TAG_FETCH_REQUEST'; +export const FAVOURITE_TAG_FETCH_SUCCESS = 'FAVOURITE_TAG_FETCH_SUCCESS'; +export const FAVOURITE_TAG_FETCH_FAIL = 'FAVOURITE_TAG_FETCH_FAIL'; + +export const FAVOURITE_TAGS_FETCH_REQUEST = 'FAVOURITE_TAGS_FETCH_REQUEST'; +export const FAVOURITE_TAGS_FETCH_SUCCESS = 'FAVOURITE_TAGS_FETCH_SUCCESS'; +export const FAVOURITE_TAGS_FETCH_FAIL = 'FAVOURITE_TAGS_FETCH_FAIL'; + +export const fetchFavouriteTag = id => (dispatch, getState) => { + if (getState().getIn(['favourite_tags', id])) { + return; + } + + dispatch(fetchFavouriteTagRequest(id)); + + api(getState).get(`/api/v1/favourite_tags/${id}`) + .then(({ data }) => dispatch(fetchFavouriteTagSuccess(data))) + .catch(err => dispatch(fetchFavouriteTagFail(id, err))); +}; + +export const fetchFavouriteTagRequest = id => ({ + type: FAVOURITE_TAG_FETCH_REQUEST, + id, +}); + +export const fetchFavouriteTagSuccess = favourite_tag => ({ + type: FAVOURITE_TAG_FETCH_SUCCESS, + favourite_tag, +}); + +export const fetchFavouriteTagFail = (id, error) => ({ + type: FAVOURITE_TAG_FETCH_FAIL, + id, + error, +}); + +export const fetchFavouriteTags = () => (dispatch, getState) => { + dispatch(fetchFavouriteTagsRequest()); + + api(getState).get('/api/v1/favourite_tags') + .then(({ data }) => dispatch(fetchFavouriteTagsSuccess(data))) + .catch(err => dispatch(fetchFavouriteTagsFail(err))); +}; + +export const fetchFavouriteTagsRequest = () => ({ + type: FAVOURITE_TAGS_FETCH_REQUEST, +}); + +export const fetchFavouriteTagsSuccess = favourite_tags => ({ + type: FAVOURITE_TAGS_FETCH_SUCCESS, + favourite_tags, +}); + +export const fetchFavouriteTagsFail = error => ({ + type: FAVOURITE_TAGS_FETCH_FAIL, + error, +}); diff --git a/app/javascript/mastodon/features/ui/components/favourite_tag_panel.js b/app/javascript/mastodon/features/ui/components/favourite_tag_panel.js new file mode 100644 index 0000000000000000000000000000000000000000..01193da56956b229e7bfd3cc3efeea74b1c64a6c --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/favourite_tag_panel.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { NavLink, withRouter } from 'react-router-dom'; +import Icon from 'mastodon/components/icon'; + +const getOrderedTags = createSelector([state => state.get('favourite_tags')], favourite_tags => { + if (!favourite_tags) { + return favourite_tags; + } + + return favourite_tags.toList().filter(item => !!item).sort((a, b) => a.get('updated_at').localeCompare(b.get('updated_at'))).take(10); +}); + +const mapStateToProps = state => ({ + favourite_tags: getOrderedTags(state), +}); + +export default @withRouter +@connect(mapStateToProps) +class FavouriteTagPanel extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + favourite_tags: ImmutablePropTypes.list, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchFavouriteTags()); + } + + render () { + const { favourite_tags } = this.props; + + if (!favourite_tags || favourite_tags.isEmpty()) { + return null; + } + + return ( + <div> + <hr /> + + {favourite_tags.map(favourite_tag => ( + <NavLink key={favourite_tag.get('id')} className='column-link column-link--transparent' strict to={`/timelines/tag/${favourite_tag.get('name')}`}><Icon className='column-link__icon' id='hashtag' fixedWidth />{favourite_tag.get('name')}</NavLink> + ))} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index a2cd6b41ab896f17bcce9c3ef7a9a3c794cde5ca..cfb6bc613fb7dbf907033e98beb7858a46fd96da 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -6,6 +6,7 @@ import { profile_directory, showTrends } from 'mastodon/initial_state'; import NotificationsCounterIcon from './notifications_counter_icon'; import FollowRequestsNavLink from './follow_requests_nav_link'; import ListPanel from './list_panel'; +import FavouriteTagPanel from './favourite_tag_panel'; import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container'; const NavigationPanel = () => ( @@ -23,6 +24,7 @@ const NavigationPanel = () => ( {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>} <ListPanel /> + <FavouriteTagPanel /> <hr /> diff --git a/app/javascript/mastodon/reducers/favourite_tags.js b/app/javascript/mastodon/reducers/favourite_tags.js new file mode 100644 index 0000000000000000000000000000000000000000..ca27107fd0cd6dc0cd4b9e3b90e28aa24c49d4af --- /dev/null +++ b/app/javascript/mastodon/reducers/favourite_tags.js @@ -0,0 +1,31 @@ +import { + FAVOURITE_TAG_FETCH_SUCCESS, + FAVOURITE_TAG_FETCH_FAIL, + FAVOURITE_TAGS_FETCH_SUCCESS, +} from '../actions/favourite_tags'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeFavouriteTag = (state, favourite_tag) => state.set(favourite_tag.id, fromJS(favourite_tag)); + +const normalizeFavouriteTags = (state, favourite_tags) => { + favourite_tags.forEach(favourite_tag => { + state = normalizeFavouriteTag(state, favourite_tag); + }); + + return state; +}; + +export default function favourite_tags(state = initialState, action) { + switch(action.type) { + case FAVOURITE_TAG_FETCH_SUCCESS: + return normalizeFavouriteTag(state, action.favourite_tag); + case FAVOURITE_TAGS_FETCH_SUCCESS: + return normalizeFavouriteTags(state, action.favourite_tags); + case FAVOURITE_TAG_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index b8d60888815471295d95a174bca4212bb2c5e73c..b34d6ebdfb5875ab982a11f1465a69b2e8a0410b 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -34,6 +34,7 @@ import polls from './polls'; import identity_proofs from './identity_proofs'; import trends from './trends'; import missed_updates from './missed_updates'; +import favourite_tags from './favourite_tags'; const reducers = { dropdown_menu, @@ -71,6 +72,7 @@ const reducers = { polls, trends, missed_updates, + favourite_tags, }; export default combineReducers(reducers); diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 499edbf4ebb2abf9edf2308204b3a942e129d926..f901c446d1ae46cc898b5a4209a930ca9e1ecdd5 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -58,6 +58,7 @@ module AccountAssociations # Hashtags has_and_belongs_to_many :tags + has_many :favourite_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account end end diff --git a/app/models/favourite_tag.rb b/app/models/favourite_tag.rb new file mode 100644 index 0000000000000000000000000000000000000000..c23719d9c0fadd12a4d86fcd7edbd2de3fbf3fb1 --- /dev/null +++ b/app/models/favourite_tag.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: favourite_tags +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# tag_id :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# + +class FavouriteTag < ApplicationRecord + belongs_to :account, inverse_of: :favourite_tags, required: true + belongs_to :tag, inverse_of: :favourite_tags, required: true + + delegate :name, to: :tag, allow_nil: true + + validates_associated :tag, on: :create + validates :name, presence: true, on: :create + validate :validate_favourite_tags_limit, on: :create + + def name=(str) + self.tag = Tag.find_or_create_by_names(str.strip)&.first + end + + private + + def validate_favourite_tags_limit + errors.add(:base, I18n.t('favourite_tags.errors.limit')) if account.favourite_tags.count >= 10 + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb index bce76fc1667b43c8d700aa28c2928b21fcf94d64..8e6fc404dbeb779e4fabc7c502f207ea1175b676 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -22,6 +22,7 @@ class Tag < ApplicationRecord has_and_belongs_to_many :accounts has_and_belongs_to_many :sample_accounts, -> { local.discoverable.popular.limit(3) }, class_name: 'Account' + has_many :favourite_tags, dependent: :destroy, inverse_of: :tag has_many :featured_tags, dependent: :destroy, inverse_of: :tag has_one :account_tag_stat, dependent: :destroy diff --git a/app/serializers/rest/favourite_tag_serializer.rb b/app/serializers/rest/favourite_tag_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..07940434d4c4f8e718c78aae21d9b31357050ea8 --- /dev/null +++ b/app/serializers/rest/favourite_tag_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::FavouriteTagSerializer < ActiveModel::Serializer + attributes :id, :name, :updated_at + + def id + object.id.to_s + end +end diff --git a/app/views/settings/favourite_tags/index.html.haml b/app/views/settings/favourite_tags/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..47044fe8066e0d4a0eda05003a5ef91bec254dda --- /dev/null +++ b/app/views/settings/favourite_tags/index.html.haml @@ -0,0 +1,26 @@ +- content_for :page_title do + = t('settings.favourite_tags') + +%p= t('favourite_tags.hint_html') + +%hr.spacer/ + += simple_form_for @favourite_tag, url: settings_favourite_tags_path do |f| + = render 'shared/error_messages', object: @favourite_tag + + .fields-group + = f.input :name, wrapper: :with_block_label, hint: false + + .actions + = f.button :button, t('favourite_tags.add_new'), type: :submit + +%hr.spacer/ + +- @favourite_tags.each do |favourite_tag| + .directory__tag{ class: params[:tag] == favourite_tag.name ? 'active' : nil } + %div + %h4 + = fa_icon 'hashtag' + = favourite_tag.name + %small + = table_link_to 'trash', t('filters.index.delete'), settings_favourite_tag_path(favourite_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/config/locales/en.yml b/config/locales/en.yml index 5cd05e3c960cf30091bceb2dc8f8154f3dbb5038..172bc51d697e2624dddf8c06e4f477d11fa1f9c3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -727,6 +727,11 @@ en: lists: Lists mutes: You mute storage: Media storage + favourite_tags: + add_new: Add new + errors: + limit: You have already favourite the maximum amount of hashtags + hint_html: "<strong>What are favourite hashtags?</strong> They are used only for you, and are tools to help you browse using hashtags. You can quickly switch between timelines." featured_tags: add_new: Add new errors: @@ -1018,6 +1023,7 @@ en: development: Development edit_profile: Edit profile export: Data export + favourite_tags: Favourite hashtags featured_tags: Featured hashtags identity_proofs: Identity proofs import: Import diff --git a/config/locales/ja.yml b/config/locales/ja.yml index ee8efec2aca5afb414b54d663a0bb16437951995..5993feb6490fb28987d7ed834a832e0bfff57fed 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -719,6 +719,11 @@ ja: lists: リスト mutes: ミュート storage: メディア + favourite_tags: + add_new: è¿½åŠ + errors: + limit: ãŠæ°—ã«å…¥ã‚Šãƒãƒƒã‚·ãƒ¥ã‚¿ã‚°ã®ä¸Šé™ã«é”ã—ã¾ã—㟠+ hint_html: "<strong>ãŠæ°—ã«å…¥ã‚Šã®ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚°ã¨ã¯ä½•ã§ã™ã‹ï¼Ÿ</strong> ãれらã¯ã‚ãªãŸè‡ªèº«ã®ãŸã‚ã«ã ã‘使用ã•れるã€ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚°ã‚’活用ã—ãŸãƒ–ラウジングを助ã‘ã‚‹ãŸã‚ã®ãƒ„ールã§ã™ã€‚ã™ã°ã‚„ãタイムラインを切り替ãˆã‚‹ã“ã¨ãŒã§ãã¾ã™ã€‚" featured_tags: add_new: è¿½åŠ errors: @@ -1004,6 +1009,7 @@ ja: development: 開発 edit_profile: プãƒãƒ•ィールを編集 export: データã®ã‚¨ã‚¯ã‚¹ãƒãƒ¼ãƒˆ + favourite_tags: ãŠæ°—ã«å…¥ã‚Šãƒãƒƒã‚·ãƒ¥ã‚¿ã‚° featured_tags: 注目ã®ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚° identity_proofs: Identity proofs import: データã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆ diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 63475167ced229b3f3bed30096a3ff65788d8993..fdccc13448b94f4603eceabfd19934b9b7c2649f 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -138,6 +138,8 @@ ja: username: ユーザーå username_or_email: ユーザーåã¾ãŸã¯ãƒ¡ãƒ¼ãƒ«ã‚¢ãƒ‰ãƒ¬ã‚¹ whole_word: å˜èªžå…¨ä½“ã«ãƒžãƒƒãƒ + favourite_tag: + name: ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚° featured_tag: name: ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚° interactions: diff --git a/config/navigation.rb b/config/navigation.rb index eebd4f75e38ba3ba336fbe6ac2d1f93050972a99..f67e6dcc3440c64974b1ad375e28a161b2efa609 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -13,6 +13,7 @@ SimpleNavigation::Configuration.run do |navigation| n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s| s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url + s.item :favourite_tags, safe_join([fa_icon('hashtag fw'), t('settings.favourite_tags')]), settings_favourite_tags_url s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url end diff --git a/config/routes.rb b/config/routes.rb index ff308699d3f565229eb37b6c5859e05ae56d2e95..6b245a1eb7668e09616282f27a3366d6b5b1d9ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -146,6 +146,7 @@ Rails.application.routes.draw do resources :aliases, only: [:index, :create, :destroy] resources :sessions, only: [:destroy] resources :featured_tags, only: [:index, :create, :destroy] + resources :favourite_tags, only: [:index, :create, :destroy] end resources :media, only: [:show] do diff --git a/db/migrate/20190821124329_create_favourite_tags.rb b/db/migrate/20190821124329_create_favourite_tags.rb new file mode 100644 index 0000000000000000000000000000000000000000..da451f84fe8ed2bc3b5cba6b611c7c8369295a4a --- /dev/null +++ b/db/migrate/20190821124329_create_favourite_tags.rb @@ -0,0 +1,10 @@ +class CreateFavouriteTags < ActiveRecord::Migration[5.2] + def change + create_table :favourite_tags do |t| + t.references :account, foreign_key: { on_delete: :cascade } + t.references :tag, foreign_key: { on_delete: :cascade } + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0595f4661843fa6394a50f9651b2f359343a6d4b..65a200371b0aa2d7abf25ce53df8f87ca3c0572d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -302,6 +302,15 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true end + create_table "favourite_tags", force: :cascade do |t| + t.bigint "account_id" + t.bigint "tag_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_favourite_tags_on_account_id" + t.index ["tag_id"], name: "index_favourite_tags_on_tag_id" + end + create_table "favourites", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -827,6 +836,8 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade + add_foreign_key "favourite_tags", "accounts", on_delete: :cascade + add_foreign_key "favourite_tags", "tags", on_delete: :cascade add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade add_foreign_key "featured_tags", "accounts", on_delete: :cascade diff --git a/spec/fabricators/favourite_tag_fabricator.rb b/spec/fabricators/favourite_tag_fabricator.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e244043a863ed26ad5a6d051a0d8a351c15b5ea --- /dev/null +++ b/spec/fabricators/favourite_tag_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:favourite_tag) do + account + tag +end diff --git a/spec/models/favourite_tag_spec.rb b/spec/models/favourite_tag_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ff99f96f3ccdb41c73ceeade9070464cba3fb923 --- /dev/null +++ b/spec/models/favourite_tag_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe FavouriteTag, type: :model do +end