diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index bf7deac5cfc857..b478b9dc56c66b 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -3,6 +3,7 @@ class Api::BaseController < ApplicationController DEFAULT_STATUSES_LIMIT = 20 DEFAULT_ACCOUNTS_LIMIT = 40 + DEFAULT_TAGS_LIMIT = 40 include Api::RateLimitHeaders include Api::AccessTokenTrackingConcern diff --git a/app/controllers/api/v1/lists/tags_controller.rb b/app/controllers/api/v1/lists/tags_controller.rb new file mode 100644 index 00000000000000..e0ab70a4f7d927 --- /dev/null +++ b/app/controllers/api/v1/lists/tags_controller.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +class Api::V1::Lists::TagsController < 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_list + + after_action :insert_pagination_headers, only: :show + + def show + @tags = load_tags + render json: @tags, each_serializer: REST::TagSerializer + end + + def create + ApplicationRecord.transaction do + list_tags.each do |tag| + @list.tags << tag + end + end + @tags = load_tags + render json: @tags, each_serializer: REST::TagSerializer + end + + def destroy + ListTag.where(list: @list, tag_id: tag_ids).destroy_all + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:list_id]) + end + + def load_tags + if unlimited? + @list.tags.all + else + @list.tags.paginate_by_max_id(limit_param(DEFAULT_TAGS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def list_tags + names = tag_ids.grep_v(/\A[0-9]+\Z/) + ids = tag_ids.grep(/\A[0-9]+\Z/) + existing_by_name = Tag.where(name: names.map { |n| Tag.normalize(n) }).select(:id, :name) + ids.push(*existing_by_name.map(&:id)) + not_existing_by_name = names.reject { |n| existing_by_name.any? { |e| e.name == Tag.normalize(n) } } + created = Tag.find_or_create_by_names(not_existing_by_name) + ids.push(*created.map(&:id)) + Tag.find(ids) + end + + def tag_ids + Array(resource_params[:tag_ids]) + end + + def resource_params + params.permit(tag_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_list_tags_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_list_tags_url pagination_params(since_id: pagination_since_id) unless @tags.empty? + end + + def pagination_max_id + @tags.last.id + end + + def pagination_since_id + @tags.first.id + end + + def records_continue? + @tags.size == limit_param(DEFAULT_TAGS_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/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 5fbc9bb5bbb64a..6b1b0b35141ec8 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -6,6 +6,7 @@ export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; +export const TAGS_IMPORT = 'TAGS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -29,6 +30,10 @@ export function importPolls(polls) { return { type: POLLS_IMPORT, polls }; } +export function importTags(tags) { + return { type: TAGS_IMPORT, tags }; +} + export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -49,6 +54,17 @@ export function importFetchedAccounts(accounts) { return importAccounts({ accounts: normalAccounts }); } +export function importFetchedTags(tags) { + return (dispatch) => { + const uniqueTags = []; + function processTag(tag) { + pushUnique(uniqueTags, tag); + } + tags.forEach(processTag); + dispatch(importTags(uniqueTags)); + }; +} + export function importFetchedStatus(status) { return importFetchedStatuses([status]); } diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js index b0789cd426450a..a986bbca2f0a86 100644 --- a/app/javascript/flavours/glitch/actions/lists.js +++ b/app/javascript/flavours/glitch/actions/lists.js @@ -1,7 +1,7 @@ import api from '../api'; import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedAccounts, importFetchedTags } from './importer'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; @@ -31,6 +31,10 @@ export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; +export const LIST_TAGS_FETCH_REQUEST = 'LIST_TAGS_FETCH_REQUEST'; +export const LIST_TAGS_FETCH_SUCCESS = 'LIST_TAGS_FETCH_SUCCESS'; +export const LIST_TAGS_FETCH_FAIL = 'LIST_TAGS_FETCH_FAIL'; + export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; @@ -118,6 +122,7 @@ export const setupListEditor = listId => (dispatch, getState) => { }); dispatch(fetchListAccounts(listId)); + dispatch(fetchListTags(listId)); }; export const changeListEditorTitle = value => ({ @@ -234,6 +239,33 @@ export const fetchListAccountsFail = (id, error) => ({ error, }); +export const fetchListTags = listId => (dispatch, getState) => { + dispatch(fetchListTagsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/tags`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedTags(data)); + dispatch(fetchListTagsSuccess(listId, data)); + }).catch(err => dispatch(fetchListTagsFail(listId, err))); +}; + +export const fetchListTagsFail = (id, error) => ({ + type: LIST_TAGS_FETCH_FAIL, + id, + error, +}); + +export const fetchListTagsRequest = id => ({ + type: LIST_TAGS_FETCH_REQUEST, + id, +}); + +export const fetchListTagsSuccess = (id, tags, next) => ({ + type: LIST_TAGS_FETCH_SUCCESS, + id, + tags, + next, +}); + export const fetchListSuggestions = q => (dispatch, getState) => { const params = { q, @@ -263,65 +295,84 @@ export const changeListSuggestions = value => ({ value, }); -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +export const addToListEditor = (id, type) => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), id, type)); }; -export const addToList = (listId, accountId) => (dispatch, getState) => { - dispatch(addToListRequest(listId, accountId)); - - api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); +export const addToList = (listId, id, type) => (dispatch, getState) => { + dispatch(addToListRequest(listId, id, type)); + + if ('tags' === type) { + api(getState).post(`/api/v1/lists/${listId}/tags`, { tag_ids: [id] }) + .then((data) => dispatch(addToListSuccess(listId, id, type, data))) + .catch(err => dispatch(addToListFail(listId, id, type, err))); + } else { + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [id] }) + .then(() => dispatch(addToListSuccess(listId, id, type))) + .catch(err => dispatch(addToListFail(listId, id, type, err))); + } }; -export const addToListRequest = (listId, accountId) => ({ +export const addToListRequest = (listId, id, type) => ({ type: LIST_EDITOR_ADD_REQUEST, + addType: type, listId, - accountId, + id, }); -export const addToListSuccess = (listId, accountId) => ({ +export const addToListSuccess = (listId, id, type, data) => ({ type: LIST_EDITOR_ADD_SUCCESS, + addType: type, listId, - accountId, + id, + data, }); -export const addToListFail = (listId, accountId, error) => ({ +export const addToListFail = (listId, id, type, error) => ({ type: LIST_EDITOR_ADD_FAIL, + addType: type, listId, - accountId, + id, error, }); -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +export const removeFromListEditor = (id, type) => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), id, type)); }; -export const removeFromList = (listId, accountId) => (dispatch, getState) => { - dispatch(removeFromListRequest(listId, accountId)); +export const removeFromList = (listId, id, type) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, id, type)); - api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); + if ('tags' === type) { + api(getState).delete(`/api/v1/lists/${listId}/tags`, { params: { tag_ids: [id] } }) + .then(() => dispatch(removeFromListSuccess(listId, id, type))) + .catch(err => dispatch(removeFromListFail(listId, id, type, err))); + } else { + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [id] } }) + .then(() => dispatch(removeFromListSuccess(listId, id, type))) + .catch(err => dispatch(removeFromListFail(listId, id, type, err))); + } }; -export const removeFromListRequest = (listId, accountId) => ({ +export const removeFromListRequest = (listId, id, type) => ({ type: LIST_EDITOR_REMOVE_REQUEST, + removeType: type, listId, - accountId, + id, }); -export const removeFromListSuccess = (listId, accountId) => ({ +export const removeFromListSuccess = (listId, id, type) => ({ type: LIST_EDITOR_REMOVE_SUCCESS, + removeType: type, listId, - accountId, + id, }); -export const removeFromListFail = (listId, accountId, error) => ({ +export const removeFromListFail = (listId, id, type, error) => ({ type: LIST_EDITOR_REMOVE_FAIL, + removeType: type, listId, - accountId, + id, error, }); diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx index 77d32af80a25a2..29a1ddb151714a 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx +++ b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx @@ -32,8 +32,8 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch, { accountId }) => ({ - onRemove: () => dispatch(removeFromListEditor(accountId)), - onAdd: () => dispatch(addToListEditor(accountId)), + onRemove: () => dispatch(removeFromListEditor(accountId, 'accounts')), + onAdd: () => dispatch(addToListEditor(accountId, 'accounts')), }); class Account extends ImmutablePureComponent { diff --git a/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx b/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx new file mode 100644 index 00000000000000..1bd4a9e965ced8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; +import TagIcon from '@/material-icons/400-24px/tag.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; + +import { addToListEditor, changeListSuggestions } from '../../../actions/lists'; + +const messages = defineMessages({ + addtag: { id: 'lists.addtag', defaultMessage: 'Enter a tag you\'d like to follow' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(addToListEditor(value, 'tags')), + onChange: value => dispatch(changeListSuggestions(value)), +}); + +class AddTag extends PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + value: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = e => { + //this.props.value = e.target.value; + this.props.onChange(e.target.value); + }; + + handleKeyUp = e => { + if (e.keyCode === 13) { + this.props.onSubmit(this.props.value); + } + }; + + render() { + const { value, intl } = this.props; + const hasValue = value.length > 0; + + return ( +
+ + +
+ + +
+
+ ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AddTag)); diff --git a/app/javascript/flavours/glitch/features/list_editor/components/tag.jsx b/app/javascript/flavours/glitch/features/list_editor/components/tag.jsx new file mode 100644 index 00000000000000..d7d808a52f37cd --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/tag.jsx @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import TagIcon from '@/material-icons/400-24px/tag.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; + +import { removeFromListEditor, addToListEditor } from '../../../actions/lists'; +import { IconButton } from '../../../components/icon_button'; + +const messages = defineMessages({ + remove: { id: 'lists.tag.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.tag.add', defaultMessage: 'Add to list' }, +}); + +const makeMapStateToProps = () => { + const mapStateToProps = (state, { tag, added }) => ({ + tag: tag, + added: typeof added === 'undefined' ? state.getIn(['listEditor', 'tags', 'items']).includes(tag) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { tag }) => ({ + onRemove: () => dispatch(removeFromListEditor(tag.id, 'tags')), + onAdd: () => dispatch(addToListEditor(tag.id, 'tags')), +}); + +class Tag extends ImmutablePureComponent { + + static propTypes = { + tag: PropTypes.object.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render() { + const { tag, intl, onRemove } = this.props; + + return ( +
+ +
+ {tag.name} +
+ +
+ +
+
+ ); + } + +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Tag)); diff --git a/app/javascript/flavours/glitch/features/list_editor/index.jsx b/app/javascript/flavours/glitch/features/list_editor/index.jsx index 85e90169e80b2c..1d5eaf8a3f62d9 100644 --- a/app/javascript/flavours/glitch/features/list_editor/index.jsx +++ b/app/javascript/flavours/glitch/features/list_editor/index.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; -import { injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -12,10 +12,18 @@ import { setupListEditor, clearListSuggestions, resetListEditor } from '../../ac import Motion from '../ui/util/optional_motion'; import Account from './components/account'; +import AddTag from './components/add_tag'; import EditListForm from './components/edit_list_form'; import Search from './components/search'; +import Tag from './components/tag'; + +const messages = defineMessages({ + account_tab: { id: 'lists.account_tab', defaultMessage: 'Accounts' }, + tag_tab: { id: 'lists.tag_tab', defaultMessage: 'Tags' }, +}); const mapStateToProps = state => ({ + tags: state.getIn(['listEditor', 'tags', 'items']), accountIds: state.getIn(['listEditor', 'accounts', 'items']), searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']), }); @@ -27,6 +35,9 @@ const mapDispatchToProps = dispatch => ({ }); class ListEditor extends ImmutablePureComponent { + state = { + currentTab: 'accounts', + }; static propTypes = { listId: PropTypes.string.isRequired, @@ -35,44 +46,70 @@ class ListEditor extends ImmutablePureComponent { onInitialize: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, onReset: PropTypes.func.isRequired, + tags: ImmutablePropTypes.list.isRequired, accountIds: ImmutablePropTypes.list.isRequired, searchAccountIds: ImmutablePropTypes.list.isRequired, }; - componentDidMount () { + componentDidMount() { const { onInitialize, listId } = this.props; onInitialize(listId); } - componentWillUnmount () { + componentWillUnmount() { const { onReset } = this.props; onReset(); } - render () { - const { accountIds, searchAccountIds, onClear } = this.props; - const showSearch = searchAccountIds.size > 0; + constructor(props) { + super(props); + this.switchToAccounts = this.switchToAccounts.bind(this); + this.switchToTags = this.switchToTags.bind(this); + } - return ( -
- + switchToAccounts() { + this.setState({ currentTab: 'accounts' }); + } - + switchToTags() { + this.setState({ currentTab: 'tags' }); + } -
-
- {accountIds.map(accountId => )} + render() { + const { accountIds, tags, searchAccountIds, onClear, intl } = this.props; + const showSearch = searchAccountIds.size > 0; + return ( +
{this.state.currentTab} + +
+ + +
+
+ +
+
+ {accountIds.map(accountId => )} +
+ + {showSearch &&
} + + + {({ x }) => ( +
+ {searchAccountIds.map(accountId => )} +
+ )} +
+
+
+
+ +
+
+ {tags.map(tag => )} +
- - {showSearch &&
} - - - {({ x }) => ( -
- {searchAccountIds.map(accountId => )} -
- )} -
); diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 990c8afe4ca169..857225a3ceb31a 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -53,6 +53,11 @@ "keyboard_shortcuts.bookmark": "to bookmark", "keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting", "keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots", + "lists.account_tab": "Accounts", + "lists.addtag": "Enter a tag you'd like to follow", + "lists.tag.add": "Add to list", + "lists.tag.remove": "Remove from list", + "lists.tag_tab": "Tags", "moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.", "navigation_bar.app_settings": "App settings", "navigation_bar.featured_users": "Featured users", diff --git a/app/javascript/flavours/glitch/reducers/list_editor.js b/app/javascript/flavours/glitch/reducers/list_editor.js index d3fd62adecbced..7903ba7b297b47 100644 --- a/app/javascript/flavours/glitch/reducers/list_editor.js +++ b/app/javascript/flavours/glitch/reducers/list_editor.js @@ -13,6 +13,9 @@ import { LIST_ACCOUNTS_FETCH_REQUEST, LIST_ACCOUNTS_FETCH_SUCCESS, LIST_ACCOUNTS_FETCH_FAIL, + LIST_TAGS_FETCH_REQUEST, + LIST_TAGS_FETCH_SUCCESS, + LIST_TAGS_FETCH_FAIL, LIST_EDITOR_SUGGESTIONS_READY, LIST_EDITOR_SUGGESTIONS_CLEAR, LIST_EDITOR_SUGGESTIONS_CHANGE, @@ -33,6 +36,12 @@ const initialState = ImmutableMap({ isLoading: false, }), + tags: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + suggestions: ImmutableMap({ value: '', items: ImmutableList(), @@ -80,6 +89,16 @@ export default function listEditorReducer(state = initialState, action) { map.set('loaded', true); map.set('items', ImmutableList(action.accounts.map(item => item.id))); })); + case LIST_TAGS_FETCH_REQUEST: + return state.setIn(['tags', 'isLoading'], true); + case LIST_TAGS_FETCH_FAIL: + return state.setIn(['tags', 'isLoading'], false); + case LIST_TAGS_FETCH_SUCCESS: + return state.update('tags', tags => tags.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.tags)); + })); case LIST_EDITOR_SUGGESTIONS_CHANGE: return state.setIn(['suggestions', 'value'], action.value); case LIST_EDITOR_SUGGESTIONS_READY: @@ -90,9 +109,12 @@ export default function listEditorReducer(state = initialState, action) { map.set('value', ''); })); case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + if (action.data && action.data.data !== undefined) { + return state.setIn([action.addType, 'items'], ImmutableList(action.data.data)); + } + return state.updateIn([action.addType, 'items'], list => list.unshift(action.id)); case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + return state.updateIn([action.removeType, 'items'], list => list.filterNot(item => item === action.id || item.id === action.id)); default: return state; } diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 0bec708116eb1c..9b901ff4ca62e7 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -116,3 +116,7 @@ export const getAccountHidden = createSelector([ export const getStatusList = createSelector([ (state, type) => state.getIn(['status_lists', type, 'items']), ], (items) => items.toList()); + +export const getListEditorTagList = createSelector([ + (state) => state.getIn(['listEditor', 'tags', 'items']), +], (items) => items.toList()); diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 6c4b6649b27462..4040af6a9c20c4 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -2039,7 +2039,8 @@ body > [data-popper-placement] { } } -.account__wrapper { +.account__wrapper, +.tag__wrapper { display: flex; gap: 10px; align-items: center; @@ -2111,7 +2112,8 @@ a .account__avatar { } } -.account__relationship { +.account__relationship, +.tag__relationship { white-space: nowrap; display: flex; align-items: center; @@ -7825,11 +7827,53 @@ noscript { border-radius: 8px 8px 0 0; } + .tab__container { + display: flex; + + .tab { + flex-grow: 1; + flex-basis: 0; + display: block; + padding: 5px; + background: $ui-base-color; + text-align: center; + border: 0; + color: $primary-text-color; + } + + .tab.tab__active { + font-weight: bold; + background: lighten($ui-base-color, 13%); + } + } + + .tab__inactive { + display: none; + } + .drawer__pager { height: 50vh; border-radius: 4px; } + .list_tag { + padding: 10px; // glitch: reduced padding + border-bottom: 1px solid lighten($ui-base-color, 8%); + gap: 10px; + display: flex; + justify-content: space-between; + align-items: center; + min-height: 36px; + + .list_tag__display-name { + flex-grow: 1; + } + + .list_tag__relationship { + flex-grow: 0; + } + } + .drawer__inner { border-radius: 0 0 8px 8px; diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index cdf4cdd22831d6..c71fc5c6495762 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -443,13 +443,11 @@ def filter_from_home?(status, receiver_id, crutches, timeline_type = :home) should_filter = !crutches[:following][status.in_reply_to_account_id] # and I'm not following the person it's a reply to should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply - return !!should_filter elsif status.reblog? # Filter out a reblog should_filter = crutches[:hiding_reblogs][status.account_id] # if the reblogger's reblogs are suppressed should_filter ||= crutches[:blocked_by][status.reblog.account_id] # or if the author of the reblogged status is blocking me should_filter ||= crutches[:domain_blocking][status.reblog.account.domain] # or the author's domain is blocked - return !!should_filter end diff --git a/app/models/list.rb b/app/models/list.rb index b45bd057bc7caa..c0b3243b8dabbc 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -25,6 +25,9 @@ class List < ApplicationRecord has_many :list_accounts, inverse_of: :list, dependent: :destroy has_many :accounts, through: :list_accounts + has_many :list_tags, inverse_of: :list, dependent: :destroy + has_many :tags, through: :list_tags + validates :title, presence: true validates_each :account_id, on: :create do |record, _attr, value| diff --git a/app/models/list_tag.rb b/app/models/list_tag.rb new file mode 100644 index 00000000000000..0397c501cf2d8a --- /dev/null +++ b/app/models/list_tag.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: list_tag +# +# id :bigint(8) not null, primary key +# list_id :bigint(8) not null +# tag_id :bigint(8) not null +# + +class ListTag < ApplicationRecord + belongs_to :list + belongs_to :tag + + validates :tag_id, uniqueness: { scope: :list_id } +end diff --git a/app/models/tag.rb b/app/models/tag.rb index f2168ae90470e7..98e4e6297a2325 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -28,6 +28,9 @@ class Tag < ApplicationRecord has_many :featured_tags, dependent: :destroy, inverse_of: :tag has_many :followers, through: :passive_relationships, source: :account + has_many :list_tags, inverse_of: :tag, dependent: :destroy + has_many :lists, through: :list_tags + HASHTAG_SEPARATORS = "_\u00B7\u30FB\u200c" HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]" HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]" diff --git a/app/models/user.rb b/app/models/user.rb index bf12d951d05ad2..a082c178e24dbd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,6 @@ # sign_in_count :integer default(0), not null # current_sign_in_at :datetime # last_sign_in_at :datetime -# admin :boolean default(FALSE), not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime @@ -29,7 +28,6 @@ # otp_backup_codes :string is an Array # account_id :bigint(8) not null # disabled :boolean default(FALSE), not null -# moderator :boolean default(FALSE), not null # invite_id :bigint(8) # chosen_languages :string is an Array # created_by_application_id :bigint(8) diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb index 017b572718ed3f..9e37ba11a16a9a 100644 --- a/app/serializers/rest/tag_serializer.rb +++ b/app/serializers/rest/tag_serializer.rb @@ -3,7 +3,7 @@ class REST::TagSerializer < ActiveModel::Serializer include RoutingHelper - attributes :name, :url, :history + attributes :name, :url, :history, :id attribute :following, if: :current_user? @@ -15,6 +15,10 @@ def name object.display_name end + def id + object.id.to_s + end + def following if instance_options && instance_options[:relationships] instance_options[:relationships].following_map[object.id] || false diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 71ab1ac494d53f..f006ff08983d75 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -113,6 +113,11 @@ def deliver_to_hashtag_followers! end def deliver_to_lists! + @status.tags.each do |tag| + FeedInsertWorker.push_bulk(tag.lists) do |list| + [@status.id, list.id, 'list', { 'update' => update? }] + end + end @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| FeedInsertWorker.push_bulk(lists) do |list| [@status.id, list.id, 'list', { 'update' => update? }] diff --git a/config/routes/api.rb b/config/routes/api.rb index c01d83af24c8a1..1bc4d9888b7729 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -220,6 +220,7 @@ resources :lists, only: [:index, :create, :show, :update, :destroy] do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' + resource :tags, only: [:show, :create, :destroy], controller: 'lists/tags' end namespace :featured_tags do diff --git a/db/migrate/20240309162900_list_tags.rb b/db/migrate/20240309162900_list_tags.rb new file mode 100644 index 00000000000000..eed4753ab46fa9 --- /dev/null +++ b/db/migrate/20240309162900_list_tags.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ListTags < ActiveRecord::Migration[7.1] + def change + create_table :list_tags do |t| + t.belongs_to :list, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :tag, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :list_tags, [:tag_id, :list_id], unique: true + add_index :list_tags, [:list_id, :tag_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index df7f84c65043bd..986ba18f92c56c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -584,6 +584,15 @@ t.index ["list_id", "account_id"], name: "index_list_accounts_on_list_id_and_account_id" end + create_table "list_tags", force: :cascade do |t| + t.bigint "list_id", null: false + t.bigint "tag_id", null: false + t.index ["list_id", "tag_id"], name: "index_list_tags_on_list_id_and_tag_id" + t.index ["list_id"], name: "index_list_tags_on_list_id" + t.index ["tag_id", "list_id"], name: "index_list_tags_on_tag_id_and_list_id", unique: true + t.index ["tag_id"], name: "index_list_tags_on_tag_id" + end + create_table "lists", force: :cascade do |t| t.bigint "account_id", null: false t.string "title", default: "", null: false @@ -1285,6 +1294,8 @@ add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade add_foreign_key "list_accounts", "follows", on_delete: :cascade add_foreign_key "list_accounts", "lists", on_delete: :cascade + add_foreign_key "list_tags", "lists", on_delete: :cascade + add_foreign_key "list_tags", "tags", on_delete: :cascade add_foreign_key "lists", "accounts", on_delete: :cascade add_foreign_key "login_activities", "users", on_delete: :cascade add_foreign_key "markers", "users", on_delete: :cascade