diff --git a/.haml-lint.yml b/.haml-lint.yml index 74d243a3ad..4048895806 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -10,6 +10,6 @@ linters: MiddleDot: enabled: true LineLength: - max: 300 + max: 240 # Override default value of 80 inherited from rubocop ViewLength: max: 200 # Override default value of 100 inherited from rubocop diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index 5c78de14e9..2c46cc4f9f 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -11,7 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController before_action :set_collection before_action :set_account, only: [:create] - before_action :set_collection_item, only: [:destroy] + before_action :set_collection_item, only: [:destroy, :revoke] after_action :verify_authorized @@ -32,6 +32,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController head 200 end + def revoke + authorize @collection_item, :revoke? + + RevokeCollectionItemService.new.call(@collection_item) + + head 200 + end + private def set_collection diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb index 002d167c05..fd3979f5af 100644 --- a/app/helpers/registration_helper.rb +++ b/app/helpers/registration_helper.rb @@ -18,4 +18,12 @@ module RegistrationHelper def ip_blocked?(remote_ip) IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists? end + + def terms_agreement_label + if TermsOfService.live.exists? + t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path) + else + t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path) + end + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fd631ce92e..44113f3d47 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -23,6 +23,16 @@ module SettingsHelper ) end + def author_attribution_name(account) + return if account.nil? + + link_to(root_url, class: 'story__details__shared__author-link') do + safe_join( + [image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))] + ) + end + end + def session_device_icon(session) device = session.detection.device diff --git a/app/javascript/mastodon/api_types/profile.ts b/app/javascript/mastodon/api_types/profile.ts index 7968f008ed..9814bddde9 100644 --- a/app/javascript/mastodon/api_types/profile.ts +++ b/app/javascript/mastodon/api_types/profile.ts @@ -1,4 +1,5 @@ import type { ApiAccountFieldJSON } from './accounts'; +import type { ApiFeaturedTagJSON } from './tags'; export interface ApiProfileJSON { id: string; @@ -20,6 +21,7 @@ export interface ApiProfileJSON { show_media_replies: boolean; show_featured: boolean; attribution_domains: string[]; + featured_tags: ApiFeaturedTagJSON[]; } export type ApiProfileUpdateParams = Partial< diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 01d7f9e4b6..52093689bc 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -17,16 +17,6 @@ export interface ApiHashtagJSON extends ApiHashtagBase { } export interface ApiFeaturedTagJSON extends ApiHashtagBase { - statuses_count: number; + statuses_count: string; last_status_at: string | null; } - -export function hashtagToFeaturedTag(tag: ApiHashtagJSON): ApiFeaturedTagJSON { - return { - id: tag.id, - name: tag.name, - url: tag.url, - statuses_count: 0, - last_status_at: null, - }; -} diff --git a/app/javascript/mastodon/components/column_back_button.tsx b/app/javascript/mastodon/components/column_back_button.tsx index 8012ba7df6..bb6939e24c 100644 --- a/app/javascript/mastodon/components/column_back_button.tsx +++ b/app/javascript/mastodon/components/column_back_button.tsx @@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; +import { useColumnIndexContext } from '../features/ui/components/columns_area'; + import { useAppHistory } from './router'; type OnClickCallback = () => void; @@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); + const columnIndex = useColumnIndexContext(); const component = ( - @@ -221,7 +226,7 @@ export const ColumnHeader: React.FC = ({ !pinned && ((multiColumn && history.location.state?.fromMastodon) || showBackButton) ) { - backButton = ; + backButton = ; } const collapsedContent = [extraContent]; @@ -260,6 +265,7 @@ export const ColumnHeader: React.FC = ({ const hasIcon = icon && iconComponent; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasTitle = (hasIcon || backButton) && title; + const columnIndex = useColumnIndexContext(); const component = (
@@ -272,6 +278,7 @@ export const ColumnHeader: React.FC = ({ onClick={handleTitleClick} className='column-header__title' type='button' + id={getColumnSkipLinkId(columnIndex)} > {!backButton && hasIcon && ( extends TextInputProps { * Customise the rendering of each option. * The rendered content must not contain other interactive content! */ - renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; + renderItem: ( + item: T, + state: ComboboxItemState, + ) => React.ReactElement | string; /** * The main selection handler, called when an option is selected or deselected. */ diff --git a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx index 9baef18668..43002013a3 100644 --- a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx +++ b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx @@ -50,6 +50,9 @@ const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => { await userEvent.keyboard('gh'); await confirmHotkey('goToHome'); + await userEvent.keyboard('ge'); + await confirmHotkey('goToExplore'); + await userEvent.keyboard('gn'); await confirmHotkey('goToNotifications'); @@ -106,6 +109,9 @@ export const Default = { goToHome: () => { setMatchedHotkey('goToHome'); }, + goToExplore: () => { + setMatchedHotkey('goToExplore'); + }, goToNotifications: () => { setMatchedHotkey('goToNotifications'); }, diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index c62fc0c20a..751ec01fe5 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -118,6 +118,7 @@ const hotkeyMatcherMap = { openMedia: just('e'), onTranslate: just('t'), goToHome: sequence('g', 'h'), + goToExplore: sequence('g', 'e'), goToNotifications: sequence('g', 'n'), goToLocal: sequence('g', 'l'), goToFederated: sequence('g', 't'), diff --git a/app/javascript/mastodon/components/modal_shell/index.tsx b/app/javascript/mastodon/components/modal_shell/index.tsx index 8b6fdcc6ad..8b06087532 100644 --- a/app/javascript/mastodon/components/modal_shell/index.tsx +++ b/app/javascript/mastodon/components/modal_shell/index.tsx @@ -1,16 +1,14 @@ import classNames from 'classnames'; -interface SimpleComponentProps { +interface ModalShellProps { className?: string; children?: React.ReactNode; } -interface ModalShellComponent extends React.FC { - Body: React.FC; - Actions: React.FC; -} - -export const ModalShell: ModalShellComponent = ({ children, className }) => { +export const ModalShell: React.FC = ({ + children, + className, +}) => { return (
{ ); }; -const ModalShellBody: ModalShellComponent['Body'] = ({ +export const ModalShellBody: React.FC = ({ children, className, }) => { @@ -39,7 +37,7 @@ const ModalShellBody: ModalShellComponent['Body'] = ({ ); }; -const ModalShellActions: ModalShellComponent['Actions'] = ({ +export const ModalShellActions: React.FC = ({ children, className, }) => { @@ -51,6 +49,3 @@ const ModalShellActions: ModalShellComponent['Actions'] = ({
); }; - -ModalShell.Body = ModalShellBody; -ModalShell.Actions = ModalShellActions; diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx index 78eb981402..f0bba5a745 100644 --- a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -1,9 +1,9 @@ import type { ChangeEventHandler, FC } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; -import { useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; -import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; +import type { ApiHashtagJSON } from '@/mastodon/api_types/tags'; import { Combobox } from '@/mastodon/components/form_fields'; import { addFeaturedTag, @@ -15,10 +15,50 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import classes from '../styles.module.scss'; +type SearchResult = Omit & { + label?: string; +}; + +const messages = defineMessages({ + placeholder: { + id: 'account_edit_tags.search_placeholder', + defaultMessage: 'Enter a hashtag…', + }, + addTag: { + id: 'account_edit_tags.add_tag', + defaultMessage: 'Add #{tagName}', + }, +}); + export const AccountEditTagSearch: FC = () => { - const { query, isLoading, results } = useAppSelector( - (state) => state.profileEdit.search, - ); + const intl = useIntl(); + + const { + query, + isLoading, + results: rawResults, + } = useAppSelector((state) => state.profileEdit.search); + const results = useMemo(() => { + if (!rawResults) { + return []; + } + + const results: SearchResult[] = [...rawResults]; // Make array mutable + const trimmedQuery = query.trim(); + if ( + trimmedQuery.length > 0 && + results.every( + (result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(), + ) + ) { + results.push({ + id: 'new', + name: trimmedQuery, + label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }), + }); + } + return results; + }, [intl, query, rawResults]); const dispatch = useAppDispatch(); const handleSearchChange: ChangeEventHandler = useCallback( @@ -28,10 +68,8 @@ export const AccountEditTagSearch: FC = () => { [dispatch], ); - const intl = useIntl(); - const handleSelect = useCallback( - (item: ApiFeaturedTagJSON) => { + (item: SearchResult) => { void dispatch(clearSearch()); void dispatch(addFeaturedTag({ name: item.name })); }, @@ -42,11 +80,8 @@ export const AccountEditTagSearch: FC = () => { { ); }; -const renderItem = (item: ApiFeaturedTagJSON) =>

#{item.name}

; +const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`; diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index 4095707a26..dbcdad6d62 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -3,15 +3,15 @@ import type { FC } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { Tag } from '@/mastodon/components/tags/tag'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import type { TagData } from '@/mastodon/reducers/slices/profile_edit'; import { addFeaturedTag, deleteFeaturedTag, - fetchFeaturedTags, + fetchProfile, fetchSuggestedTags, } from '@/mastodon/reducers/slices/profile_edit'; import { @@ -35,9 +35,9 @@ const messages = defineMessages({ const selectTags = createAppSelector( [(state) => state.profileEdit], (profileEdit) => ({ - tags: profileEdit.tags ?? [], + tags: profileEdit.profile?.featuredTags ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [], - isLoading: !profileEdit.tags || !profileEdit.tagSuggestions, + isLoading: !profileEdit.profile || !profileEdit.tagSuggestions, isPending: profileEdit.isPending, }), ); @@ -52,7 +52,7 @@ export const AccountEditFeaturedTags: FC = () => { const dispatch = useAppDispatch(); useEffect(() => { - void dispatch(fetchFeaturedTags()); + void dispatch(fetchProfile()); void dispatch(fetchSuggestedTags()); }, [dispatch]); @@ -78,7 +78,9 @@ export const AccountEditFeaturedTags: FC = () => { defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.' tagName='p' /> + + {tagSuggestions.length > 0 && (
{ ))}
)} + {isLoading && } + { ); }; -function renderTag(tag: ApiFeaturedTagJSON) { +function renderTag(tag: TagData) { return (

#{tag.name}

- {tag.statuses_count > 0 && ( + {tag.statusesCount > 0 && ( )} diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index da58088e89..e0c03bd536 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -15,10 +15,7 @@ import { useElementHandledLink } from '@/mastodon/components/status/handled_link import { useAccount } from '@/mastodon/hooks/useAccount'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { autoPlayGif } from '@/mastodon/initial_state'; -import { - fetchFeaturedTags, - fetchProfile, -} from '@/mastodon/reducers/slices/profile_edit'; +import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; @@ -87,9 +84,8 @@ export const AccountEdit: FC = () => { const dispatch = useAppDispatch(); - const { profile, tags = [] } = useAppSelector((state) => state.profileEdit); + const { profile } = useAppSelector((state) => state.profileEdit); useEffect(() => { - void dispatch(fetchFeaturedTags()); void dispatch(fetchProfile()); }, [dispatch]); @@ -127,7 +123,7 @@ export const AccountEdit: FC = () => { const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const hasName = !!profile.displayName; const hasBio = !!profile.bio; - const hasTags = tags.length > 0; + const hasTags = profile.featuredTags.length > 0; return ( { /> } > - {tags.map((tag) => `#${tag.name}`).join(', ')} + {profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')} - + - - + + - + ); }; diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 3bff066ee6..137794d95b 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -13,7 +13,11 @@ import { AvatarGroup } from 'mastodon/components/avatar_group'; import { Button } from 'mastodon/components/button'; import { CopyLinkField } from 'mastodon/components/form_fields'; import { IconButton } from 'mastodon/components/icon_button'; -import { ModalShell } from 'mastodon/components/modal_shell'; +import { + ModalShell, + ModalShellActions, + ModalShellBody, +} from 'mastodon/components/modal_shell'; import { useAppDispatch } from 'mastodon/store'; import { AuthorNote } from '.'; @@ -64,7 +68,7 @@ export const CollectionShareModal: React.FC<{ return ( - +

{isNew ? ( - + - +
- + ); }; diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx index 8a6ebe6def..d2b041ec3f 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx @@ -134,6 +134,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { g+h + + g+e + + g+n diff --git a/app/javascript/mastodon/features/navigation_panel/index.tsx b/app/javascript/mastodon/features/navigation_panel/index.tsx index 0dbe94cc21..4f2c490d5a 100644 --- a/app/javascript/mastodon/features/navigation_panel/index.tsx +++ b/app/javascript/mastodon/features/navigation_panel/index.tsx @@ -33,6 +33,7 @@ import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { WordmarkLogo } from 'mastodon/components/logo'; import { Search } from 'mastodon/features/compose/components/search'; import { ColumnLink } from 'mastodon/features/ui/components/column_link'; +import { getNavigationSkipLinkId } from 'mastodon/features/ui/components/skip_links'; import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; import { useIdentity } from 'mastodon/identity_context'; import { @@ -224,7 +225,11 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({ return (
- +
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 753f7e9ac3..5609a52b31 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { Children, cloneElement, useCallback } from 'react'; +import { Children, cloneElement, createContext, useContext, useCallback } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -53,6 +53,13 @@ const TabsBarPortal = () => { return
; }; +// Simple context to allow column children to know which column they're in +export const ColumnIndexContext = createContext(1); +/** + * @returns {number} + */ +export const useColumnIndexContext = () => useContext(ColumnIndexContext); + export default class ColumnsArea extends ImmutablePureComponent { static propTypes = { columns: ImmutablePropTypes.list.isRequired, @@ -140,18 +147,22 @@ export default class ColumnsArea extends ImmutablePureComponent { return (
- {columns.map(column => { + {columns.map((column, index) => { const params = column.get('params', null) === null ? null : column.get('params').toJS(); const other = params && params.other ? params.other : {}; return ( - - {SpecificComponent => } - + + + {SpecificComponent => } + + ); })} - {Children.map(children, child => cloneElement(child, { multiColumn: true }))} + + {Children.map(children, child => cloneElement(child, { multiColumn: true }))} +
); } diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 385ec6a794..5fbf7fff66 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -3,7 +3,11 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import { Button } from 'mastodon/components/button'; -import { ModalShell } from 'mastodon/components/modal_shell'; +import { + ModalShell, + ModalShellActions, + ModalShellBody, +} from 'mastodon/components/modal_shell'; export interface BaseConfirmationModalProps { onClose: () => void; @@ -58,14 +62,14 @@ export const ConfirmationModal: React.FC< return ( - +

{title}

{message &&

{message}

} {extraContent ?? children} -
+ - +