From 87490a32205a1019c8e8bf49ecbc7eb8353bc8b2 Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 26 Aug 2023 13:25:57 +0900 Subject: [PATCH] Wip: bookmark statuses view and adder --- .../statuses_controller.rb | 94 +++++++++++++++++++ .../api/v1/bookmark_categories_controller.rb | 47 ++++++++++ .../bookmark_categories_controller.rb | 18 ++++ .../mastodon/actions/bookmark_categories.js | 4 +- .../mastodon/components/status_action_bar.jsx | 7 ++ .../mastodon/containers/status_container.jsx | 9 ++ ...orm.jsx => new_bookmark_category_form.jsx} | 0 .../features/bookmark_categories/index.jsx | 6 +- .../components/account.jsx | 43 +++++++++ .../components/bookmark_category.jsx | 72 ++++++++++++++ .../bookmark_category_adder/index.jsx | 77 +++++++++++++++ .../bookmark_category_statuses/index.jsx | 4 +- .../features/status/components/action_bar.jsx | 1 + .../mastodon/features/status/index.jsx | 10 ++ .../features/ui/components/modal_root.jsx | 2 + .../features/ui/util/async-components.js | 4 + .../mastodon/reducers/bookmark_categories.js | 18 +++- .../reducers/bookmark_category_adder.js | 48 ++++++++++ .../reducers/bookmark_category_editor.js | 18 ++-- app/javascript/mastodon/reducers/index.ts | 2 + .../mastodon/reducers/status_lists.js | 13 ++- app/javascript/mastodon/reducers/statuses.js | 8 ++ app/models/bookmark_category.rb | 29 ++++++ app/models/bookmark_category_status.rb | 34 +++++++ app/models/concerns/account_associations.rb | 3 + app/models/status.rb | 2 + .../rest/bookmark_category_serializer.rb | 9 ++ config/routes/api.rb | 5 + ...230826023400_create_bookmark_categories.rb | 27 ++++++ db/schema.rb | 26 ++++- 30 files changed, 616 insertions(+), 24 deletions(-) create mode 100644 app/controllers/api/v1/bookmark_categories/statuses_controller.rb create mode 100644 app/controllers/api/v1/bookmark_categories_controller.rb create mode 100644 app/controllers/api/v1/statuses/bookmark_categories_controller.rb rename app/javascript/mastodon/features/bookmark_categories/components/{new_list_form.jsx => new_bookmark_category_form.jsx} (100%) create mode 100644 app/javascript/mastodon/features/bookmark_category_adder/components/account.jsx create mode 100644 app/javascript/mastodon/features/bookmark_category_adder/components/bookmark_category.jsx create mode 100644 app/javascript/mastodon/features/bookmark_category_adder/index.jsx create mode 100644 app/javascript/mastodon/reducers/bookmark_category_adder.js create mode 100644 app/models/bookmark_category.rb create mode 100644 app/models/bookmark_category_status.rb create mode 100644 app/serializers/rest/bookmark_category_serializer.rb create mode 100644 db/migrate/20230826023400_create_bookmark_categories.rb diff --git a/app/controllers/api/v1/bookmark_categories/statuses_controller.rb b/app/controllers/api/v1/bookmark_categories/statuses_controller.rb new file mode 100644 index 0000000000..a195fce97d --- /dev/null +++ b/app/controllers/api/v1/bookmark_categories/statuses_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class Api::V1::BookmarkCategories::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_bookmark_category + + after_action :insert_pagination_headers, only: :show + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + def create + ApplicationRecord.transaction do + bookmark_category_statuses.each do |status| + Bookmark.find_or_create_by!(account: current_account, status: status) + @bookmark_category.statuses << status + end + end + + render_empty + end + + def destroy + BookmarkCategoryStatus.where(bookmark_category: @bookmark_category, status_id: status_ids).destroy_all + render_empty + end + + private + + def set_bookmark_category + @bookmark_category = current_account.bookmark_categories.find(params[:bookmark_category_id]) + end + + def load_statuses + if unlimited? + @bookmark_category.statuses.includes(:status_stat).all + else + @bookmark_category.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + end + end + + def bookmark_category_statuses + Status.find(status_ids) + end + + def status_ids + Array(resource_params[:status_ids]) + end + + def resource_params + params.permit(status_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_bookmark_category_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_bookmark_category_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/bookmark_categories_controller.rb b/app/controllers/api/v1/bookmark_categories_controller.rb new file mode 100644 index 0000000000..c32828630d --- /dev/null +++ b/app/controllers/api/v1/bookmark_categories_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::BookmarkCategoriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] + + before_action :require_user! + before_action :set_bookmark_category, except: [:index, :create] + + rescue_from ArgumentError do |e| + render json: { error: e.to_s }, status: 422 + end + + def index + @bookmark_categories = BookmarkCategory.where(account: current_account).all + render json: @bookmark_categories, each_serializer: REST::BookmarkCategorySerializer + end + + def show + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def create + @bookmark_category = BookmarkCategory.create!(bookmark_category_params.merge(account: current_account)) + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def update + @bookmark_category.update!(bookmark_category_params) + render json: @bookmark_category, serializer: REST::BookmarkCategorySerializer + end + + def destroy + @bookmark_category.destroy! + render_empty + end + + private + + def set_bookmark_category + @bookmark_category = BookmarkCategory.where(account: current_account).find(params[:id]) + end + + def bookmark_category_params + params.permit(:title) + end +end diff --git a/app/controllers/api/v1/statuses/bookmark_categories_controller.rb b/app/controllers/api/v1/statuses/bookmark_categories_controller.rb new file mode 100644 index 0000000000..9d65b96296 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmark_categories_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarkCategoriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_status + + def index + @statuses = @status.deleted_at.present? ? [] : @status.joined_bookmark_categories.where(account: current_account) + render json: @statuses, each_serializer: REST::BookmarkCategorySerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/javascript/mastodon/actions/bookmark_categories.js b/app/javascript/mastodon/actions/bookmark_categories.js index b386525391..3f8f790009 100644 --- a/app/javascript/mastodon/actions/bookmark_categories.js +++ b/app/javascript/mastodon/actions/bookmark_categories.js @@ -150,10 +150,10 @@ export const createBookmarkCategoryFail = error => ({ error, }); -export const updateBookmarkCategory = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { +export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => { dispatch(updateBookmarkCategoryRequest(id)); - api(getState).put(`/api/v1/bookmark_categories/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { + api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => { dispatch(updateBookmarkCategorySuccess(data)); if (shouldReset) { diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 84087a566b..cd647cb379 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -37,6 +37,7 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + bookmarkCategory: { id: 'status.bookmark_category', defaultMessage: 'Bookmark category' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, @@ -92,6 +93,7 @@ class StatusActionBar extends ImmutablePureComponent { onMuteConversation: PropTypes.func, onPin: PropTypes.func, onBookmark: PropTypes.func, + onBookmarkCategoryAdder: PropTypes.func, onFilter: PropTypes.func, onAddFilter: PropTypes.func, onInteractionModal: PropTypes.func, @@ -167,6 +169,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onBookmark(this.props.status); }; + handleBookmarkCategoryAdderClick = () => { + this.props.onBookmarkCategoryAdder(this.props.status); + }; + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); }; @@ -300,6 +306,7 @@ class StatusActionBar extends ImmutablePureComponent { } menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); + menu.push({ text: intl.formatMessage(messages.bookmarkCategory), action: this.handleBookmarkCategoryAdderClick }); if (writtenByMe && pinnableStatus) { menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 18c99385f5..5023b7ef03 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -142,6 +142,15 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onBookmarkCategoryAdder (status) { + dispatch(openModal({ + modalType: 'BOOKMARK_CATEGORY_ADDER', + modalProps: { + statusId: status.get('id'), + }, + })); + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); diff --git a/app/javascript/mastodon/features/bookmark_categories/components/new_list_form.jsx b/app/javascript/mastodon/features/bookmark_categories/components/new_bookmark_category_form.jsx similarity index 100% rename from app/javascript/mastodon/features/bookmark_categories/components/new_list_form.jsx rename to app/javascript/mastodon/features/bookmark_categories/components/new_bookmark_category_form.jsx diff --git a/app/javascript/mastodon/features/bookmark_categories/index.jsx b/app/javascript/mastodon/features/bookmark_categories/index.jsx index 14b5b15e1e..c3f53b8a73 100644 --- a/app/javascript/mastodon/features/bookmark_categories/index.jsx +++ b/app/javascript/mastodon/features/bookmark_categories/index.jsx @@ -17,11 +17,12 @@ import ScrollableList from 'mastodon/components/scrollable_list'; import ColumnLink from 'mastodon/features/ui/components/column_link'; import ColumnSubheading from 'mastodon/features/ui/components/column_subheading'; -import NewListForm from './components/new_list_form'; +import NewListForm from './components/new_bookmark_category_form'; const messages = defineMessages({ heading: { id: 'column.bookmark_categories', defaultMessage: 'Bookmark categories' }, - subheading: { id: 'bookmark_categories_ex.subheading', defaultMessage: 'Your categories' }, + subheading: { id: 'bookmark_categories.subheading', defaultMessage: 'Your categories' }, + allBookmarks: { id: 'bookmark_categories.all_bookmarks', defaultMessage: 'All bookmarks' }, }); const getOrderedCategories = createSelector([state => state.get('bookmark_categories')], categories => { @@ -69,6 +70,7 @@ class BookmarkCategories extends ImmutablePureComponent { + , { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + return ( +
+
+
+
+ +
+
+
+ ); + } + +} + +export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/bookmark_category_adder/components/bookmark_category.jsx b/app/javascript/mastodon/features/bookmark_category_adder/components/bookmark_category.jsx new file mode 100644 index 0000000000..f9fdb6b8e6 --- /dev/null +++ b/app/javascript/mastodon/features/bookmark_category_adder/components/bookmark_category.jsx @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { Icon } from 'mastodon/components/icon'; + +import { removeFromBookmarkCategoryAdder, addToBookmarkCategoryAdder } from '../../../actions/bookmark_categories'; +import { IconButton } from '../../../components/icon_button'; + +const messages = defineMessages({ + remove: { id: 'bookmark_categories.status.remove', defaultMessage: 'Remove from bookmark category' }, + add: { id: 'bookmark_categories.status.add', defaultMessage: 'Add to bookmark category' }, +}); + +const MapStateToProps = (state, { bookmarkCategoryId, added }) => ({ + bookmarkCategory: state.get('bookmark_categories').get(bookmarkCategoryId), + added: typeof added === 'undefined' ? state.getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']).includes(bookmarkCategoryId) : added, +}); + +const mapDispatchToProps = (dispatch, { bookmarkCategoryId }) => ({ + onRemove: () => dispatch(removeFromBookmarkCategoryAdder(bookmarkCategoryId)), + onAdd: () => dispatch(addToBookmarkCategoryAdder(bookmarkCategoryId)), +}); + +class BookmarkCategory extends ImmutablePureComponent { + + static propTypes = { + bookmarkCategory: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { bookmarkCategory, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = ; + } else { + button = ; + } + + return ( +
+
+
+ + {bookmarkCategory.get('title')} +
+ +
+ {button} +
+
+
+ ); + } + +} + +export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategory)); diff --git a/app/javascript/mastodon/features/bookmark_category_adder/index.jsx b/app/javascript/mastodon/features/bookmark_category_adder/index.jsx new file mode 100644 index 0000000000..c929d82a2d --- /dev/null +++ b/app/javascript/mastodon/features/bookmark_category_adder/index.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; + +import { injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { setupBookmarkCategoryAdder, resetBookmarkCategoryAdder } from '../../actions/bookmark_categories'; +import NewBookmarkCategoryForm from '../bookmark_categories/components/new_bookmark_category_form'; + +// import Account from './components/account'; +import BookmarkCategory from './components/bookmark_category'; + +const getOrderedBookmarkCategories = createSelector([state => state.get('bookmark_categories')], bookmarkCategories => { + if (!bookmarkCategories) { + return bookmarkCategories; + } + + return bookmarkCategories.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + bookmarkCategoryIds: getOrderedBookmarkCategories(state).map(bookmarkCategory=>bookmarkCategory.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: statusId => dispatch(setupBookmarkCategoryAdder(statusId)), + onReset: () => dispatch(resetBookmarkCategoryAdder()), +}); + +class BookmarkCategoryAdder extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + bookmarkCategoryIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, statusId } = this.props; + onInitialize(statusId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { bookmarkCategoryIds } = this.props; + + return ( +
+ {/* +
+ +
+ */} + + + + +
+ {bookmarkCategoryIds.map(BookmarkCategoryId => )} +
+
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategoryAdder)); diff --git a/app/javascript/mastodon/features/bookmark_category_statuses/index.jsx b/app/javascript/mastodon/features/bookmark_category_statuses/index.jsx index 4c89d64c71..6ffd017891 100644 --- a/app/javascript/mastodon/features/bookmark_category_statuses/index.jsx +++ b/app/javascript/mastodon/features/bookmark_category_statuses/index.jsx @@ -99,8 +99,8 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent { return ( { + this.props.dispatch(openModal({ + modalType: 'BOOKMARK_CATEGORY_ADDER', + modalProps: { + statusId: status.get('id'), + }, + })); + }; + handleDeleteClick = (status, history, withRedraft = false) => { const { dispatch, intl } = this.props; @@ -737,6 +746,7 @@ class Status extends ImmutablePureComponent { onReblogForceModal={this.handleReblogForceModalClick} onReference={this.handleReference} onBookmark={this.handleBookmarkClick} + onBookmarkCategoryAdder={this.handleBookmarkCategoryAdderClick} onDelete={this.handleDeleteClick} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 89be83017a..4d41ff4e0d 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -15,6 +15,7 @@ import { AntennaAdder, CircleEditor, CircleAdder, + BookmarkCategoryAdder, CompareHistoryModal, FilterModal, InteractionModal, @@ -55,6 +56,7 @@ export const MODAL_COMPONENTS = { 'LIST_ADDER': ListAdder, 'ANTENNA_ADDER': AntennaAdder, 'CIRCLE_ADDER': CircleAdder, + 'BOOKMARK_CATEGORY_ADDER': BookmarkCategoryAdder, 'COMPARE_HISTORY': CompareHistoryModal, 'FILTER': FilterModal, 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index adbe7e49a7..81d83ec818 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -130,6 +130,10 @@ export function BookmarkCategoryStatuses () { return import(/* webpackChunkName: "features/bookmark_category_statuses" */'../../bookmark_category_statuses'); } +export function BookmarkCategoryAdder () { + return import(/* webpackChunkName: "features/bookmark_category_adder" */'../../bookmark_category_adder'); +} + export function Blocks () { return import(/* webpackChunkName: "features/blocks" */'../../blocks'); } diff --git a/app/javascript/mastodon/reducers/bookmark_categories.js b/app/javascript/mastodon/reducers/bookmark_categories.js index b9bd332216..731dceee85 100644 --- a/app/javascript/mastodon/reducers/bookmark_categories.js +++ b/app/javascript/mastodon/reducers/bookmark_categories.js @@ -14,6 +14,9 @@ import { BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS, BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL, } from '../actions/bookmark_categories'; +import { + UNBOOKMARK_SUCCESS, +} from '../actions/interactions'; const initialState = ImmutableMap(); @@ -27,8 +30,8 @@ const normalizeBookmarkCategories = (state, bookmarkCategories) => { return state; }; -const normalizeBookmarkCategoryStatuses = (state, bookmaryCategoryId, statuses, next) => { - return state.updateIn([bookmaryCategoryId, 'items'], listMap => listMap.withMutations(map => { +const normalizeBookmarkCategoryStatuses = (state, bookmarkCategoryId, statuses, next) => { + return state.update(bookmarkCategoryId, listMap => listMap.withMutations(map => { map.set('next', next); map.set('loaded', true); map.set('isLoading', false); @@ -37,13 +40,20 @@ const normalizeBookmarkCategoryStatuses = (state, bookmaryCategoryId, statuses, }; const appendToBookmarkCategoryStatuses = (state, bookmarkCategoryId, statuses, next) => { - return state.updateIn([bookmarkCategoryId, 'items'], listMap => listMap.withMutations(map => { + return state.update(bookmarkCategoryId, listMap => listMap.withMutations(map => { map.set('next', next); map.set('isLoading', false); map.set('items', map.get('items').union(statuses.map(item => item.id))); })); }; +const removeStatusFromAllBookmarkCategories = (state, status) => { + state.toList().forEach((bookmarkCategory) => { + state = state.updateIn([bookmarkCategory.get('id'), 'items'], items => items.delete(status.get('id'))); + }); + return state; +}; + export default function bookmarkCategories(state = initialState, action) { switch(action.type) { case BOOKMARK_CATEGORY_FETCH_SUCCESS: @@ -65,6 +75,8 @@ export default function bookmarkCategories(state = initialState, action) { return normalizeBookmarkCategoryStatuses(state, action.id, action.statuses, action.next); case BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS: return appendToBookmarkCategoryStatuses(state, action.id, action.statuses, action.next); + case UNBOOKMARK_SUCCESS: + return removeStatusFromAllBookmarkCategories(state, action.status); default: return state; } diff --git a/app/javascript/mastodon/reducers/bookmark_category_adder.js b/app/javascript/mastodon/reducers/bookmark_category_adder.js new file mode 100644 index 0000000000..05148d3336 --- /dev/null +++ b/app/javascript/mastodon/reducers/bookmark_category_adder.js @@ -0,0 +1,48 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; + +import { + BOOKMARK_CATEGORY_ADDER_RESET, + BOOKMARK_CATEGORY_ADDER_SETUP, + BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST, + BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS, + BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL, + BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, + BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS, +} from '../actions/bookmark_categories'; + +const initialState = ImmutableMap({ + statusId: null, + + bookmarkCategories: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function bookmarkCategoryAdderReducer(state = initialState, action) { + switch(action.type) { + case BOOKMARK_CATEGORY_ADDER_RESET: + return initialState; + case BOOKMARK_CATEGORY_ADDER_SETUP: + return state.withMutations(map => { + map.set('statusId', action.status.get('id')); + }); + case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST: + return state.setIn(['bookmarkCategories', 'isLoading'], true); + case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL: + return state.setIn(['bookmarkCategories', 'isLoading'], false); + case BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS: + return state.update('bookmarkCategories', bookmarkCategories => bookmarkCategories.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.bookmarkCategories.map(item => item.id))); + })); + case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS: + return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.unshift(action.bookmarkCategoryId)); + case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['bookmarkCategories', 'items'], bookmarkCategory => bookmarkCategory.filterNot(item => item === action.bookmarkCategoryId)); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/bookmark_category_editor.js b/app/javascript/mastodon/reducers/bookmark_category_editor.js index 640489f364..2226fd7327 100644 --- a/app/javascript/mastodon/reducers/bookmark_category_editor.js +++ b/app/javascript/mastodon/reducers/bookmark_category_editor.js @@ -10,12 +10,10 @@ import { BOOKMARK_CATEGORY_EDITOR_RESET, BOOKMARK_CATEGORY_EDITOR_SETUP, BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE, - BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, - BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS, } from '../actions/bookmark_categories'; const initialState = ImmutableMap({ - bookmaryCategoryId: null, + bookmarkCategoryId: null, isSubmitting: false, isChanged: false, title: '', @@ -33,15 +31,15 @@ const initialState = ImmutableMap({ }), }); -export default function bookmaryCategoryEditorReducer(state = initialState, action) { +export default function bookmarkCategoryEditorReducer(state = initialState, action) { switch(action.type) { case BOOKMARK_CATEGORY_EDITOR_RESET: return initialState; case BOOKMARK_CATEGORY_EDITOR_SETUP: return state.withMutations(map => { - map.set('bookmaryCategoryId', action.bookmaryCategory.get('id')); - map.set('title', action.bookmaryCategory.get('title')); - map.set('isExclusive', action.bookmaryCategory.get('is_exclusive')); + map.set('bookmarkCategoryId', action.bookmarkCategory.get('id')); + map.set('title', action.bookmarkCategory.get('title')); + map.set('isExclusive', action.bookmarkCategory.get('is_exclusive')); map.set('isSubmitting', false); }); case BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE: @@ -62,12 +60,8 @@ export default function bookmaryCategoryEditorReducer(state = initialState, acti case BOOKMARK_CATEGORY_UPDATE_SUCCESS: return state.withMutations(map => { map.set('isSubmitting', false); - map.set('bookmaryCategoryId', action.bookmaryCategory.id); + map.set('bookmarkCategoryId', action.bookmarkCategory.id); }); - case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS: - return state.updateIn(['accounts', 'items'], bookmaryCategory => bookmaryCategory.unshift(action.accountId)); - case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['accounts', 'items'], bookmaryCategory => bookmaryCategory.filterNot(item => item === action.accountId)); default: return state; } diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 37455d8085..05c24b7dfc 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -13,6 +13,7 @@ import antennaEditor from './antenna_editor'; import antennas from './antennas'; import blocks from './blocks'; import bookmark_categories from './bookmark_categories'; +import bookmarkCategoryAdder from './bookmark_category_adder'; import bookmarkCategoryEditor from './bookmark_category_editor'; import boosts from './boosts'; import circleAdder from './circle_adder'; @@ -93,6 +94,7 @@ const reducers = { circleAdder, bookmark_categories, bookmarkCategoryEditor, + bookmarkCategoryAdder, filters, conversations, suggestions, diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 6786bbf4d7..f5501921f8 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -4,6 +4,9 @@ import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS, } from '../actions/accounts'; +import { + BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS, +} from '../actions/bookmark_categories'; import { BOOKMARKED_STATUSES_FETCH_REQUEST, BOOKMARKED_STATUSES_FETCH_SUCCESS, @@ -98,11 +101,15 @@ const appendToList = (state, listType, statuses, next) => { }; const prependOneToList = (state, listType, status) => { + return prependOneToListById(state, listType, status.get('id')); +}; + +const prependOneToListById = (state, listType, statusId) => { return state.updateIn([listType, 'items'], (list) => { - if (list.includes(status.get('id'))) { + if (list.includes(statusId)) { return list; } else { - return ImmutableOrderedSet([status.get('id')]).union(list); + return ImmutableOrderedSet([statusId]).union(list); } }); }; @@ -163,6 +170,8 @@ export default function statusLists(state = initialState, action) { return removeOneFromList(state, 'emoji_reactions', action.status); case BOOKMARK_SUCCESS: return prependOneToList(state, 'bookmarks', action.status); + case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS: + return prependOneToListById(state, 'bookmarks', action.statusId); case UNBOOKMARK_SUCCESS: return removeOneFromList(state, 'bookmarks', action.status); case PINNED_STATUSES_FETCH_SUCCESS: diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index c38f9cd5a4..e589f246bf 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -1,5 +1,9 @@ import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { + BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST, + BOOKMARK_CATEGORY_EDITOR_ADD_FAIL, +} from '../actions/bookmark_categories'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { normalizeStatusTranslation } from '../actions/importer/normalizer'; import { @@ -111,6 +115,10 @@ export default function statuses(state = initialState, action) { return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true); case BOOKMARK_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); + case BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST: + return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], true); + case BOOKMARK_CATEGORY_EDITOR_ADD_FAIL: + return state.get(action.statusId) === undefined ? state : state.setIn([action.statusId, 'bookmarked'], false); case UNBOOKMARK_REQUEST: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false); case UNBOOKMARK_FAIL: diff --git a/app/models/bookmark_category.rb b/app/models/bookmark_category.rb new file mode 100644 index 0000000000..6673eb9e2e --- /dev/null +++ b/app/models/bookmark_category.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: bookmark_categories +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# title :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class BookmarkCategory < ApplicationRecord + include Paginable + + PER_CATEGORY_LIMIT = 20 + + belongs_to :account + + has_many :bookmark_category_statuses, inverse_of: :bookmark_category, dependent: :destroy + has_many :statuses, through: :bookmark_category_statuses + + validates :title, presence: true + + validates_each :account_id, on: :create do |record, _attr, value| + record.errors.add(:base, I18n.t('bookmark_categories.errors.limit')) if BookmarkCategory.where(account_id: value).count >= PER_CATEGORY_LIMIT + end +end diff --git a/app/models/bookmark_category_status.rb b/app/models/bookmark_category_status.rb new file mode 100644 index 0000000000..d932455ff5 --- /dev/null +++ b/app/models/bookmark_category_status.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: bookmark_category_statuses +# +# id :bigint(8) not null, primary key +# bookmark_category_id :bigint(8) not null +# status_id :bigint(8) not null +# bookmark_id :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# + +class BookmarkCategoryStatus < ApplicationRecord + belongs_to :bookmark_category + belongs_to :status + belongs_to :bookmark + + validates :status_id, uniqueness: { scope: :bookmark_category_id } + validate :validate_relationship + + before_validation :set_bookmark + + private + + def set_bookmark + self.bookmark = Bookmark.find_by!(account_id: bookmark_category.account_id, status_id: status_id) + end + + def validate_relationship + errors.add(:account_id, 'bookmark relationship missing') if bookmark_id.blank? + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index abda458e96..94301abeb1 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -15,6 +15,9 @@ module AccountAssociations has_many :favourites, inverse_of: :account, dependent: :destroy has_many :emoji_reactions, inverse_of: :account, dependent: :destroy has_many :bookmarks, inverse_of: :account, dependent: :destroy + has_many :bookmark_categories, inverse_of: :account, dependent: :destroy + has_many :circles, inverse_of: :account, dependent: :destroy + has_many :antennas, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account diff --git a/app/models/status.rb b/app/models/status.rb index 0aaea6ec38..e19f224fc8 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -84,6 +84,8 @@ class Status < ApplicationRecord has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status, dependent: :destroy has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy + has_many :bookmark_category_relationships, class_name: 'BookmarkCategoryStatus', inverse_of: :status, dependent: :destroy + has_many :joined_bookmark_categories, class_name: 'BookmarkCategory', through: :bookmark_category_relationships, source: :bookmark_category has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards diff --git a/app/serializers/rest/bookmark_category_serializer.rb b/app/serializers/rest/bookmark_category_serializer.rb new file mode 100644 index 0000000000..86e538e4c6 --- /dev/null +++ b/app/serializers/rest/bookmark_category_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::BookmarkCategorySerializer < ActiveModel::Serializer + attributes :id, :title + + def id + object.id.to_s + end +end diff --git a/config/routes/api.rb b/config/routes/api.rb index ff6f84f328..b6b12d1534 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -13,6 +13,7 @@ namespace :api, format: false do resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index resources :referred_by, controller: :referred_by_statuses, only: :index + resources :bookmark_categories, only: :index resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' @@ -228,6 +229,10 @@ namespace :api, format: false do resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' end + resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do + resource :statuses, only: [:show, :create, :destroy], controller: 'bookmark_categories/statuses' + end + namespace :featured_tags do get :suggestions, to: 'suggestions#index' end diff --git a/db/migrate/20230826023400_create_bookmark_categories.rb b/db/migrate/20230826023400_create_bookmark_categories.rb new file mode 100644 index 0000000000..17da42fbd1 --- /dev/null +++ b/db/migrate/20230826023400_create_bookmark_categories.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class CreateBookmarkCategories < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + create_table :bookmark_categories do |t| + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade } + t.string :title, null: false, default: '' + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + create_table :bookmark_category_statuses do |t| + t.belongs_to :bookmark_category, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :bookmark, null: true, foreign_key: { on_delete: :cascade } + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + + add_index :bookmark_category_statuses, [:bookmark_category_id, :status_id], unique: true, algorithm: :concurrently, name: 'index_bc_statuses' + end +end diff --git a/db/schema.rb b/db/schema.rb index 144679c85c..d4344a0832 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[7.0].define(version: 2023_08_22_041804) do +ActiveRecord::Schema[7.0].define(version: 2023_08_26_023400) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -361,6 +361,26 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_22_041804) do t.index ["target_account_id"], name: "index_blocks_on_target_account_id" end + create_table "bookmark_categories", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "title", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_bookmark_categories_on_account_id" + end + + create_table "bookmark_category_statuses", force: :cascade do |t| + t.bigint "bookmark_category_id", null: false + t.bigint "status_id", null: false + t.bigint "bookmark_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["bookmark_category_id", "status_id"], name: "index_bc_statuses", unique: true + t.index ["bookmark_category_id"], name: "index_bookmark_category_statuses_on_bookmark_category_id" + t.index ["bookmark_id"], name: "index_bookmark_category_statuses_on_bookmark_id" + t.index ["status_id"], name: "index_bookmark_category_statuses_on_status_id" + end + create_table "bookmarks", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false @@ -1359,6 +1379,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_22_041804) do add_foreign_key "backups", "users", on_delete: :nullify add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade + add_foreign_key "bookmark_categories", "accounts", on_delete: :cascade + add_foreign_key "bookmark_category_statuses", "bookmark_categories", on_delete: :cascade + add_foreign_key "bookmark_category_statuses", "bookmarks", on_delete: :cascade + add_foreign_key "bookmark_category_statuses", "statuses", on_delete: :cascade add_foreign_key "bookmarks", "accounts", on_delete: :cascade add_foreign_key "bookmarks", "statuses", on_delete: :cascade add_foreign_key "bulk_import_rows", "bulk_imports", on_delete: :cascade