diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 616159f05f..7da1add23a 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -1,25 +1,30 @@ # frozen_string_literal: true class Api::V1::Lists::AccountsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] - before_action :require_user! + before_action :require_user!, except: [:show] before_action :set_list after_action :insert_pagination_headers, only: :show def show + authorize @list, :show? @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end def create + authorize @list, :update? AddAccountsToListService.new.call(@list, Account.find(account_ids)) render_empty end def destroy + authorize @list, :update? RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids)) render_empty end @@ -27,7 +32,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController private def set_list - @list = List.where(account: current_account).find(params[:list_id]) + @list = List.find(params[:list_id]) end def load_accounts diff --git a/app/controllers/api/v1/lists/follows_controller.rb b/app/controllers/api/v1/lists/follows_controller.rb new file mode 100644 index 0000000000..519ca08935 --- /dev/null +++ b/app/controllers/api/v1/lists/follows_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Lists::FollowsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' } + before_action :require_user! + before_action :set_list + + def create + FollowFromPublicListWorker.perform_async(current_account.id, @list.id) + render json: {}, status: 202 + end + + private + + def set_list + @list = List.where(type: :public_list).find(params[:list_id]) + end +end diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb index 4bbbed2673..fd2623b014 100644 --- a/app/controllers/api/v1/lists_controller.rb +++ b/app/controllers/api/v1/lists_controller.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true class Api::V1::ListsController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show] + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index] before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show] - before_action :require_user! + before_action :require_user!, except: [:show] before_action :set_list, except: [:index, :create] rescue_from ArgumentError do |e| @@ -17,6 +20,7 @@ class Api::V1::ListsController < Api::BaseController end def show + authorize @list, :show? render json: @list, serializer: REST::ListSerializer end @@ -26,11 +30,13 @@ class Api::V1::ListsController < Api::BaseController end def update + authorize @list, :update? @list.update!(list_params) render json: @list, serializer: REST::ListSerializer end def destroy + authorize @list, :destroy? @list.destroy! render_empty end @@ -38,10 +44,10 @@ class Api::V1::ListsController < Api::BaseController private def set_list - @list = List.where(account: current_account).find(params[:id]) + @list = List.find(params[:id]) end def list_params - params.permit(:title, :replies_policy, :exclusive) + params.permit(:title, :description, :type, :replies_policy, :exclusive) end end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index d8cdbdb74c..c443eddac4 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -1,23 +1,25 @@ # frozen_string_literal: true class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:lists' } - before_action :require_user! + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:lists' } before_action :set_list before_action :set_statuses PERMITTED_PARAMS = %i(limit).freeze def show + authorize @list, :show? render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private def set_list - @list = List.where(account: current_account).find(params[:id]) + @list = List.find(params[:id]) end def set_statuses diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 4d94c80158..211d023b29 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -7,6 +7,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController layout :determine_layout before_action :set_invite, only: [:new, :create] + before_action :set_list, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] before_action :set_sessions, only: [:edit, :update] @@ -110,6 +111,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController end end + def set_list + @list = List.where(type: :public_list).find_by(id: params[:list_id]) + end + def determine_layout %w(edit update).include?(action_name) ? 'admin' : 'auth' end diff --git a/app/controllers/lists_controller.rb b/app/controllers/lists_controller.rb new file mode 100644 index 0000000000..9c77f9892b --- /dev/null +++ b/app/controllers/lists_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ListsController < ApplicationController + include WebAppControllerConcern + + before_action :set_list + + def show; end + + private + + def set_list + @list = List.where(type: :public_list).find(params[:id]) + end +end diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts index a521f3ef35..97a3819d03 100644 --- a/app/javascript/mastodon/actions/alerts.ts +++ b/app/javascript/mastodon/actions/alerts.ts @@ -5,7 +5,7 @@ import { AxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; interface Alert { - title: string | MessageDescriptor; + title?: string | MessageDescriptor; message: string | MessageDescriptor; values?: Record; } diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts index a5586eb6d4..bae357adb6 100644 --- a/app/javascript/mastodon/api/lists.ts +++ b/app/javascript/mastodon/api/lists.ts @@ -30,3 +30,6 @@ export const apiRemoveAccountFromList = (listId: string, accountId: string) => apiRequestDelete(`v1/lists/${listId}/accounts`, { account_ids: [accountId], }); + +export const apiFollowList = (listId: string) => + apiRequestPost(`v1/lists/${listId}/follow`); diff --git a/app/javascript/mastodon/api_types/lists.ts b/app/javascript/mastodon/api_types/lists.ts index 6984cf9b19..635fde7137 100644 --- a/app/javascript/mastodon/api_types/lists.ts +++ b/app/javascript/mastodon/api_types/lists.ts @@ -1,10 +1,21 @@ // See app/serializers/rest/list_serializer.rb +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + export type RepliesPolicyType = 'list' | 'followed' | 'none'; +export type ListType = 'private_list' | 'public_list'; + export interface ApiListJSON { id: string; + url?: string; title: string; + slug?: string; + type: ListType; + description: string; + created_at: string; + updated_at: string; exclusive: boolean; replies_policy: RepliesPolicyType; + account?: ApiAccountJSON; } diff --git a/app/javascript/mastodon/components/copy_icon_button.jsx b/app/javascript/mastodon/components/copy_icon_button.tsx similarity index 62% rename from app/javascript/mastodon/components/copy_icon_button.jsx rename to app/javascript/mastodon/components/copy_icon_button.tsx index 0c3c6c290b..55262a4b53 100644 --- a/app/javascript/mastodon/components/copy_icon_button.jsx +++ b/app/javascript/mastodon/components/copy_icon_button.tsx @@ -1,29 +1,36 @@ -import PropTypes from 'prop-types'; import { useState, useCallback } from 'react'; import { defineMessages } from 'react-intl'; import classNames from 'classnames'; -import { useDispatch } from 'react-redux'; - import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; import { showAlert } from 'mastodon/actions/alerts'; import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch } from 'mastodon/store'; const messages = defineMessages({ - copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' }, + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, }); -export const CopyIconButton = ({ title, value, className }) => { +export const CopyIconButton: React.FC<{ + title: string; + value: string; + className?: string; +}> = ({ title, value, className }) => { const [copied, setCopied] = useState(false); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const handleClick = useCallback(() => { - navigator.clipboard.writeText(value); + void navigator.clipboard.writeText(value); setCopied(true); dispatch(showAlert({ message: messages.copied })); - setTimeout(() => setCopied(false), 700); + setTimeout(() => { + setCopied(false); + }, 700); }, [setCopied, value, dispatch]); return ( @@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => { className={classNames(className, copied ? 'copied' : 'copyable')} title={title} onClick={handleClick} + icon='' iconComponent={ContentCopyIcon} /> ); }; - -CopyIconButton.propTypes = { - title: PropTypes.string, - value: PropTypes.string, - className: PropTypes.string, -}; diff --git a/app/javascript/mastodon/features/explore/components/author_link.jsx b/app/javascript/mastodon/features/explore/components/author_link.jsx deleted file mode 100644 index 764ae75341..0000000000 --- a/app/javascript/mastodon/features/explore/components/author_link.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; - -import { Link } from 'react-router-dom'; - -import { Avatar } from 'mastodon/components/avatar'; -import { useAppSelector } from 'mastodon/store'; - -export const AuthorLink = ({ accountId }) => { - const account = useAppSelector(state => state.getIn(['accounts', accountId])); - - if (!account) { - return null; - } - - return ( - - - - - ); -}; - -AuthorLink.propTypes = { - accountId: PropTypes.string.isRequired, -}; diff --git a/app/javascript/mastodon/features/explore/components/author_link.tsx b/app/javascript/mastodon/features/explore/components/author_link.tsx new file mode 100644 index 0000000000..cc6d3ec313 --- /dev/null +++ b/app/javascript/mastodon/features/explore/components/author_link.tsx @@ -0,0 +1,25 @@ +import { Link } from 'react-router-dom'; + +import { Avatar } from 'mastodon/components/avatar'; +import { useAppSelector } from 'mastodon/store'; + +export const AuthorLink: React.FC<{ + accountId: string; +}> = ({ accountId }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + + if (!account) { + return null; + } + + return ( + + + + + ); +}; diff --git a/app/javascript/mastodon/features/lists/index.tsx b/app/javascript/mastodon/features/lists/index.tsx index 25a537336e..2b25ee6de8 100644 --- a/app/javascript/mastodon/features/lists/index.tsx +++ b/app/javascript/mastodon/features/lists/index.tsx @@ -8,9 +8,12 @@ import { Link } from 'react-router-dom'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import PackageIcon from '@/material-icons/400-24px/package_2.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import { fetchLists } from 'mastodon/actions/lists'; import { openModal } from 'mastodon/actions/modal'; +import type { List } from 'mastodon/models/list'; +import type { MenuItems } from 'mastodon/models/dropdown_menu'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; @@ -25,12 +28,12 @@ const messages = defineMessages({ edit: { id: 'lists.edit', defaultMessage: 'Edit list' }, delete: { id: 'lists.delete', defaultMessage: 'Delete list' }, more: { id: 'status.more', defaultMessage: 'More' }, + copyLink: { id: '', defaultMessage: 'Copy link' }, }); const ListItem: React.FC<{ - id: string; - title: string; -}> = ({ id, title }) => { + list: List; +}> = ({ list }) => { const dispatch = useAppDispatch(); const intl = useIntl(); @@ -39,25 +42,54 @@ const ListItem: React.FC<{ openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { - listId: id, + listId: list.id, }, }), ); - }, [dispatch, id]); + }, [dispatch, list]); - const menu = useMemo( - () => [ - { text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` }, - { text: intl.formatMessage(messages.delete), action: handleDeleteClick }, - ], - [intl, id, handleDeleteClick], - ); + const handleCopyClick = useCallback(() => { + void navigator.clipboard.writeText(list.url); + }, [list]); + + const menu = useMemo(() => { + const tmp: MenuItems = [ + { text: intl.formatMessage(messages.edit), to: `/lists/${list.id}/edit` }, + { + text: intl.formatMessage(messages.delete), + action: handleDeleteClick, + dangerous: true, + }, + ]; + + if (list.type === 'public_list') { + tmp.unshift( + { + text: intl.formatMessage(messages.copyLink), + action: handleCopyClick, + }, + null, + ); + } + + return tmp; + }, [intl, list, handleDeleteClick]); return (
- - - {title} + + + {list.title} {lists.map((list) => ( - + ))} diff --git a/app/javascript/mastodon/features/lists/members.tsx b/app/javascript/mastodon/features/lists/members.tsx index 41d02ad9fc..ba3a96e14e 100644 --- a/app/javascript/mastodon/features/lists/members.tsx +++ b/app/javascript/mastodon/features/lists/members.tsx @@ -158,6 +158,7 @@ const ListMembers: React.FC<{ const { id } = useParams<{ id: string }>(); const intl = useIntl(); + const list = useAppSelector((state) => state.lists.get(id)); const [searching, setSearching] = useState(false); const [accountIds, setAccountIds] = useState([]); const [searchAccountIds, setSearchAccountIds] = useState([]); @@ -285,7 +286,14 @@ const ListMembers: React.FC<{ {displayedAccountIds.length > 0 &&
}
- +
diff --git a/app/javascript/mastodon/features/lists/new.tsx b/app/javascript/mastodon/features/lists/new.tsx index 100f126c37..ffac7c93bc 100644 --- a/app/javascript/mastodon/features/lists/new.tsx +++ b/app/javascript/mastodon/features/lists/new.tsx @@ -79,7 +79,9 @@ const NewList: React.FC<{ id ? state.lists.get(id) : undefined, ); const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); const [exclusive, setExclusive] = useState(false); + const [isPublic, setIsPublic] = useState(false); const [repliesPolicy, setRepliesPolicy] = useState('list'); const [submitting, setSubmitting] = useState(false); @@ -104,6 +106,13 @@ const NewList: React.FC<{ [setTitle], ); + const handleDescriptionChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setDescription(value); + }, + [setDescription], + ); + const handleExclusiveChange = useCallback( ({ target: { checked } }: React.ChangeEvent) => { setExclusive(checked); @@ -111,6 +120,13 @@ const NewList: React.FC<{ [setExclusive], ); + const handleIsPublicChange = useCallback( + ({ target: { checked } }: React.ChangeEvent) => { + setIsPublic(checked); + }, + [setIsPublic], + ); + const handleRepliesPolicyChange = useCallback( ({ target: { value } }: React.ChangeEvent) => { setRepliesPolicy(value as RepliesPolicyType); @@ -126,8 +142,10 @@ const NewList: React.FC<{ updateList({ id, title, + description, exclusive, replies_policy: repliesPolicy, + type: isPublic ? 'public_list' : 'private_list', }), ).then(() => { setSubmitting(false); @@ -137,8 +155,10 @@ const NewList: React.FC<{ void dispatch( createList({ title, + description, exclusive, replies_policy: repliesPolicy, + type: isPublic ? 'public_list' : 'private_list', }), ).then((result) => { setSubmitting(false); @@ -151,7 +171,17 @@ const NewList: React.FC<{ return ''; }); } - }, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]); + }, [ + history, + dispatch, + setSubmitting, + id, + title, + description, + exclusive, + isPublic, + repliesPolicy, + ]); return (
+
+
+
+ + +
+