diff --git a/app/controllers/api/v1/circles/statuses_controller.rb b/app/controllers/api/v1/circles/statuses_controller.rb new file mode 100644 index 00000000000000..84e9b05543a7e2 --- /dev/null +++ b/app/controllers/api/v1/circles/statuses_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::Circles::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_circle + + after_action :insert_pagination_headers, only: :show + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_circle + @circle = current_account.circles.find(params[:circle_id]) + end + + def load_statuses + if unlimited? + @circle.statuses.includes(:status_stat).all + else + @circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + end + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_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/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index 6a52e541c9a440..a497b27d5db592 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -1,7 +1,7 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; @@ -50,6 +50,14 @@ export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_RE export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; +export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST'; +export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS'; +export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL'; + +export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST'; +export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS'; +export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL'; + export const fetchCircle = id => (dispatch, getState) => { if (getState().getIn(['circles', id])) { return; @@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => { dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); }; +export function fetchCircleStatuses(circleId) { + return (dispatch, getState) => { + if (getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(fetchCircleStatusesRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchCircleStatusesFail(circleId, error)); + }); + }; +} + +export function fetchCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_FETCH_REQUEST, + id, + }; +} + +export function fetchCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_FETCH_SUCCESS, + id, + statuses, + next, + }; +} + +export function fetchCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_FETCH_FAIL, + id, + error, + }; +} + +export function expandCircleStatuses(circleId) { + return (dispatch, getState) => { + const url = getState().getIn(['circles', circleId, 'statuses', 'next'], null); + + if (url === null || getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) { + return; + } + + dispatch(expandCircleStatusesRequest(circleId)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandCircleStatusesFail(circleId, error)); + }); + }; +} + +export function expandCircleStatusesRequest(id) { + return { + type: CIRCLE_STATUSES_EXPAND_REQUEST, + id, + }; +} + +export function expandCircleStatusesSuccess(id, statuses, next) { + return { + type: CIRCLE_STATUSES_EXPAND_SUCCESS, + id, + statuses, + next, + }; +} + +export function expandCircleStatusesFail(id, error) { + return { + type: CIRCLE_STATUSES_EXPAND_FAIL, + id, + error, + }; +} + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1f682d1321b0c6..efe4c564066dc8 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; +export const COMPOSE_WITH_CIRCLE_SUCCESS = 'COMPOSE_WITH_CIRCLE_SUCCESS'; + export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -174,6 +176,7 @@ export function submitCompose(routerHistory) { const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); const statusId = getState().getIn(['compose', 'id'], null); + const circleId = getState().getIn(['compose', 'circle_id'], null); if ((!status || !status.length) && media.size === 0) { return; @@ -253,6 +256,10 @@ export function submitCompose(routerHistory) { insertIfOnline(`account:${response.data.account.id}`); } + if (statusId === null && circleId !== null && circleId !== 0) { + dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId)); + } + dispatch(showAlert({ message: statusId === null ? messages.published : messages.saved, action: messages.open, @@ -278,6 +285,14 @@ export function submitComposeSuccess(status) { }; } +export function submitComposeWithCircleSuccess(status, circleId) { + return { + type: COMPOSE_WITH_CIRCLE_SUCCESS, + status, + circleId, + } +} + export function submitComposeFail(error) { return { type: COMPOSE_SUBMIT_FAIL, diff --git a/app/javascript/mastodon/features/circle_statuses/index.jsx b/app/javascript/mastodon/features/circle_statuses/index.jsx new file mode 100644 index 00000000000000..2896455ab5c6a9 --- /dev/null +++ b/app/javascript/mastodon/features/circle_statuses/index.jsx @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { openModal } from 'mastodon/actions/modal'; +import ColumnHeader from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import StatusList from 'mastodon/components/status_list'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import Column from 'mastodon/features/ui/components/column'; +import { getCircleStatusList } from 'mastodon/selectors'; + + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' }, + deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' }, + heading: { id: 'column.circles', defaultMessage: 'Circles' }, +}); + +const mapStateToProps = (state, { params }) => ({ + circle: state.getIn(['circles', params.id]), + statusIds: getCircleStatusList(state, params.id), + isLoading: state.getIn(['circles', params.id, 'isLoading'], true), + isEditing: state.getIn(['circleEditor', 'circleId']) === params.id, + hasMore: !!state.getIn(['circles', params.id, 'next']), +}); + +class CircleStatuses extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + UNSAFE_componentWillMount () { + this.props.dispatch(fetchCircle(this.props.params.id)); + this.props.dispatch(fetchCircleStatuses(this.props.params.id)); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id })); + this.context.router.history.push('/'); + } + }; + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + handleEditClick = () => { + this.props.dispatch(openModal({ + modalType: 'CIRCLE_EDITOR', + modalProps: { circleId: this.props.params.id }, + })); + }; + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteCircle(id)); + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/circles'); + } + }, + }, + })); + }; + + setRef = c => { + this.column = c; + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandCircleStatuses()); + }, 300, { leading: true }); + + render () { + const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + if (typeof circle === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (circle === false) { + return ( + + ); + } + + const emptyMessage = ; + + return ( + + +
+ + + +
+
+ + + + + {intl.formatMessage(messages.heading)} + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(CircleStatuses)); diff --git a/app/javascript/mastodon/features/circles/index.jsx b/app/javascript/mastodon/features/circles/index.jsx index 1b83876827fd0d..1cd3ae417fdbf1 100644 --- a/app/javascript/mastodon/features/circles/index.jsx +++ b/app/javascript/mastodon/features/circles/index.jsx @@ -13,7 +13,6 @@ import { fetchCircles, deleteCircle } from 'mastodon/actions/circles'; import { openModal } from 'mastodon/actions/modal'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import { IconButton } from 'mastodon/components/icon_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; import ColumnLink from 'mastodon/features/ui/components/column_link'; @@ -106,10 +105,7 @@ class Circles extends ImmutablePureComponent { bindToDocument={!multiColumn} > {circles.map(circle => - (
- - -
) + , )} diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index af549d21edd749..280330f5309cf2 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -24,6 +24,7 @@ import { BookmarkCategoryStatuses, AntennaSetting, AntennaTimeline, + CircleStatuses, } from '../util/async-components'; import BundleColumnError from './bundle_column_error'; @@ -45,6 +46,7 @@ const componentMap = { 'EMOJI_REACTIONS': EmojiReactedStatuses, 'BOOKMARKS': BookmarkedStatuses, 'BOOKMARKS_EX': BookmarkCategoryStatuses, + 'CIRCLE_STATUSES': CircleStatuses, 'ANTENNA': AntennaSetting, 'ANTENNA_TIMELINE': AntennaTimeline, 'LIST': ListTimeline, diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 64a1e302e5a4ac..feef2a7559887b 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -65,6 +65,7 @@ import { Lists, Antennas, Circles, + CircleStatuses, AntennaSetting, Directory, Explore, @@ -259,6 +260,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 81d83ec818354f..10daf553574923 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -54,6 +54,10 @@ export function Circles () { return import(/* webpackChunkName: "features/circles" */'../../circles'); } +export function CircleStatuses () { + return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js index 805d7f186addf5..96237219d9065c 100644 --- a/app/javascript/mastodon/reducers/circles.js +++ b/app/javascript/mastodon/reducers/circles.js @@ -1,4 +1,4 @@ -import { List as ImmutableList, fromJS } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { CIRCLE_FETCH_SUCCESS, @@ -7,31 +7,106 @@ import { CIRCLE_CREATE_SUCCESS, CIRCLE_UPDATE_SUCCESS, CIRCLE_DELETE_SUCCESS, + CIRCLE_STATUSES_FETCH_REQUEST, + CIRCLE_STATUSES_FETCH_SUCCESS, + CIRCLE_STATUSES_FETCH_FAIL, + CIRCLE_STATUSES_EXPAND_REQUEST, + CIRCLE_STATUSES_EXPAND_SUCCESS, + CIRCLE_STATUSES_EXPAND_FAIL, } from '../actions/circles'; +import { + COMPOSE_WITH_CIRCLE_SUCCESS, +} from '../actions/compose'; const initialState = ImmutableList(); -const normalizeList = (state, circle) => state.set(circle.id, fromJS(circle)); +const initialStatusesState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + next: null, +}); + +const normalizeCircle = (state, circle) => { + const old = state.get(circle.id); + if (old === false) { + return state; + } + + let s = state.set(circle.id, fromJS(circle)); + if (old) { + s = s.setIn([circle.id, 'statuses'], old.get('statuses')); + } else { + s = s.setIn([circle.id, 'statuses'], initialStatusesState); + } + return s; +}; -const normalizeLists = (state, circles) => { +const normalizeCircles = (state, circles) => { circles.forEach(circle => { - state = normalizeList(state, circle); + state = normalizeCircle(state, circle); }); return state; }; +const normalizeCircleStatuses = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('isLoading', false); + map.set('items', ImmutableOrderedSet(statuses.map(item => item.id))); + })); +}; + +const appendToCircleStatuses = (state, circleId, statuses, next) => { + return appendToCircleStatusesById(state, circleId, statuses.map(item => item.id), next); +}; + +const appendToCircleStatusesById = (state, circleId, statuses, next) => { + return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => { + if (typeof next !== 'undefined') { + map.set('next', next); + } + map.set('isLoading', false); + if (map.get('items')) { + map.set('items', map.get('items').union(statuses)); + } + })); +}; + +const prependToCircleStatusById = (state, circleId, statusId) => { + if (!state.get(circleId)) return state; + + return state.updateIn([circleId], circle => circle.withMutations(map => { + if (map.getIn(['statuses', 'items'])) { + map.updateIn(['statuses', 'items'], list => ImmutableOrderedSet([statusId]).union(list)); + } + })); +} + export default function circles(state = initialState, action) { switch(action.type) { case CIRCLE_FETCH_SUCCESS: case CIRCLE_CREATE_SUCCESS: case CIRCLE_UPDATE_SUCCESS: - return normalizeList(state, action.circle); + return normalizeCircle(state, action.circle); case CIRCLES_FETCH_SUCCESS: - return normalizeLists(state, action.circles); + return normalizeCircles(state, action.circles); case CIRCLE_DELETE_SUCCESS: case CIRCLE_FETCH_FAIL: return state.set(action.id, false); + case CIRCLE_STATUSES_FETCH_REQUEST: + case CIRCLE_STATUSES_EXPAND_REQUEST: + return state.setIn([action.id, 'statuses', 'isLoading'], true); + case CIRCLE_STATUSES_FETCH_FAIL: + case CIRCLE_STATUSES_EXPAND_FAIL: + return state.setIn([action.id, 'statuses', 'isLoading'], false); + case CIRCLE_STATUSES_FETCH_SUCCESS: + return normalizeCircleStatuses(state, action.id, action.statuses, action.next); + case CIRCLE_STATUSES_EXPAND_SUCCESS: + return appendToCircleStatuses(state, action.id, action.statuses, action.next); + case COMPOSE_WITH_CIRCLE_SUCCESS: + return prependToCircleStatusById(state, action.circleId, action.status.id); default: return state; } diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 6d5adbeb48f5a7..f6398e93240600 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -135,3 +135,7 @@ export const getStatusList = createSelector([ export const getBookmarkCategoryStatusList = createSelector([ (state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']), ], (items) => items ? items.toList() : ImmutableList()); + +export const getCircleStatusList = createSelector([ + (state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']), +], (items) => items ? items.toList() : ImmutableList()); diff --git a/app/models/circle.rb b/app/models/circle.rb index dcb29a91abb5ea..7c11ab59ea0961 100644 --- a/app/models/circle.rb +++ b/app/models/circle.rb @@ -20,6 +20,8 @@ class Circle < ApplicationRecord has_many :circle_accounts, inverse_of: :circle, dependent: :destroy has_many :accounts, through: :circle_accounts + has_many :circle_statuses, inverse_of: :circle, dependent: :destroy + has_many :statuses, through: :circle_statuses validates :title, presence: true diff --git a/app/models/circle_status.rb b/app/models/circle_status.rb new file mode 100644 index 00000000000000..b394a4f9270904 --- /dev/null +++ b/app/models/circle_status.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: circle_statuses +# +# id :bigint(8) not null, primary key +# circle_id :bigint(8) +# status_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CircleStatus < ApplicationRecord + belongs_to :circle + belongs_to :status + + validates :status, uniqueness: { scope: :circle } + validate :account_own_status + + private + + def account_own_status + errors.add(:status_id, :invalid) unless status.account_id == circle.account_id + end +end diff --git a/app/models/status.rb b/app/models/status.rb index b532f58a3083a6..98b274ce046cc9 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -106,6 +106,7 @@ class Status < ApplicationRecord has_one :poll, inverse_of: :status, dependent: :destroy has_one :trend, class_name: 'StatusTrend', inverse_of: :status has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy + has_one :circle_status, inverse_of: :status, dependent: :destroy validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 493facbea773c5..0a5b48a9d9e654 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -112,5 +112,7 @@ def process_circle! @circle.accounts.find_each do |target_account| @current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) end + + @circle.statuses << @status end end diff --git a/config/routes.rb b/config/routes.rb index 3db26c7f8c0439..ed5995b2f4042b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ /lists/(*any) /antennasw/(*any) /antennast/(*any) - /circles + /circles/(*any) /notifications /favourites /emoji_reactions diff --git a/config/routes/api.rb b/config/routes/api.rb index 9bf466ddab5f9f..961cd43ad35198 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -226,6 +226,7 @@ resources :circles, only: [:index, :create, :show, :update, :destroy] do resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' + resource :statuses, only: [:show], controller: 'circles/statuses' end resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do diff --git a/db/migrate/20230923103430_create_circle_statuses.rb b/db/migrate/20230923103430_create_circle_statuses.rb new file mode 100644 index 00000000000000..9c14bb808ab8b9 --- /dev/null +++ b/db/migrate/20230923103430_create_circle_statuses.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class CreateCircleStatuses < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + create_table :circle_statuses do |t| + t.belongs_to :circle, null: true, foreign_key: { on_delete: :cascade } + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + + add_index :circle_statuses, [:circle_id, :status_id], unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c5dcfc6a330f56..e17320cdaa0637 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_09_19_232836) do +ActiveRecord::Schema[7.0].define(version: 2023_09_23_103430) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -447,6 +447,16 @@ t.index ["follow_id"], name: "index_circle_accounts_on_follow_id" end + create_table "circle_statuses", force: :cascade do |t| + t.bigint "circle_id" + t.bigint "status_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["circle_id", "status_id"], name: "index_circle_statuses_on_circle_id_and_status_id", unique: true + t.index ["circle_id"], name: "index_circle_statuses_on_circle_id" + t.index ["status_id"], name: "index_circle_statuses_on_status_id" + end + create_table "circles", force: :cascade do |t| t.bigint "account_id", null: false t.string "title", default: "", null: false @@ -1414,6 +1424,8 @@ add_foreign_key "circle_accounts", "accounts", on_delete: :cascade add_foreign_key "circle_accounts", "circles", on_delete: :cascade add_foreign_key "circle_accounts", "follows", on_delete: :cascade + add_foreign_key "circle_statuses", "circles", on_delete: :cascade + add_foreign_key "circle_statuses", "statuses", on_delete: :cascade add_foreign_key "circles", "accounts", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 0db73c41fabdbb..39bb355577ebf4 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -103,4 +103,27 @@ end end end + + context 'with circle post' do + let(:status) { Fabricate(:status, account: account) } + let(:circle) { Fabricate(:circle, account: account) } + let(:follower) { Fabricate(:account) } + let(:other) { Fabricate(:account) } + + before do + follower.follow!(account) + other.follow!(account) + circle.accounts << follower + described_class.new.call(status, limited_type: :circle, circle: circle) + end + + it 'remains circle post on history' do + expect(CircleStatus.exists?(circle_id: circle.id, status_id: status.id)).to be true + end + + it 'post is delivered to circle members' do + expect(status.mentioned_accounts.count).to eq 1 + expect(status.mentioned_accounts.first.id).to eq follower.id + end + end end