diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb new file mode 100644 index 0000000000..cc2e5cdef1 --- /dev/null +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionItemsController < Api::BaseController + include Authorization + + before_action :check_feature_enabled + + before_action -> { doorkeeper_authorize! :write, :'write:collections' } + + before_action :require_user! + + before_action :set_collection + before_action :set_account, only: [:create] + + after_action :verify_authorized + + def create + authorize @collection, :update? + authorize @account, :feature? + + @item = AddAccountToCollectionService.new.call(@collection, @account) + + render json: @item, serializer: REST::CollectionItemSerializer + end + + private + + def set_collection + @collection = Collection.find(params[:collection_id]) + end + + def set_account + return render(json: { error: '`account_id` parameter is missing' }, status: 422) if params[:account_id].blank? + + @account = Account.find(params[:account_id]) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 2bdd355864..1e83ab9c69 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -72,10 +72,13 @@ module SignatureVerification rescue Mastodon::SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e + @signature_verification_failure_code ||= 503 fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError + @signature_verification_failure_code ||= 503 fail_with! 'Failed to fetch remote data (got unexpected reply from server)' rescue Stoplight::Error::RedLight + @signature_verification_failure_code ||= 503 fail_with! 'Fetching attempt skipped because of recent connection failure' end diff --git a/app/javascript/images/icons/icon_planet.svg b/app/javascript/images/icons/icon_planet.svg new file mode 100644 index 0000000000..e8bf34c7a6 --- /dev/null +++ b/app/javascript/images/icons/icon_planet.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/mastodon/actions/search.ts b/app/javascript/mastodon/actions/search.ts index 1e57c30715..4f21a53b4d 100644 --- a/app/javascript/mastodon/actions/search.ts +++ b/app/javascript/mastodon/actions/search.ts @@ -144,7 +144,7 @@ export const hydrateSearch = createAppAsyncThunk( 'search/hydrate', (_args, { dispatch, getState }) => { const me = getState().meta.get('me') as string; - const history = searchHistory.get(me) as RecentSearch[] | null; + const history = searchHistory.get(me); if (history !== null) { dispatch(updateSearchHistory(history)); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 056e7d7b23..d97885bad1 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,6 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import api, { getLinks } from 'mastodon/api'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; @@ -32,7 +31,6 @@ export const TIMELINE_GAP = null; export const TIMELINE_NON_STATUS_MARKERS = [ TIMELINE_GAP, TIMELINE_SUGGESTIONS, - TIMELINE_WRAPSTODON, ]; export const loadPending = timeline => ({ @@ -132,7 +130,6 @@ export function expandTimeline(timelineId, path, params = {}) { if (timelineId === 'home') { dispatch(submitMarkers()); - dispatch(reinsertAnnualReport()) } } catch(error) { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx index a874f4792e..39ae11422a 100644 --- a/app/javascript/mastodon/components/dismissable_banner.tsx +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -1,12 +1,10 @@ -import type { PropsWithChildren } from 'react'; -import { useCallback, useState, useEffect } from 'react'; +import type { FC, ReactNode } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { changeSetting } from 'mastodon/actions/settings'; -import { bannerSettings } from 'mastodon/settings'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { useDismissible } from '../hooks/useDismissible'; import { IconButton } from './icon_button'; @@ -16,48 +14,12 @@ const messages = defineMessages({ interface Props { id: string; + children: ReactNode; } -export function useDismissableBannerState({ id }: Props) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const dismissed: boolean = useAppSelector((state) => - /* eslint-disable-next-line */ - state.settings.getIn(['dismissed_banners', id], false), - ); - - const [isVisible, setIsVisible] = useState( - !bannerSettings.get(id) && !dismissed, - ); - - const dispatch = useAppDispatch(); - - const dismiss = useCallback(() => { - setIsVisible(false); - bannerSettings.set(id, true); - dispatch(changeSetting(['dismissed_banners', id], true)); - }, [id, dispatch]); - - useEffect(() => { - // Store legacy localStorage setting on server - if (!isVisible && !dismissed) { - dispatch(changeSetting(['dismissed_banners', id], true)); - } - }, [id, dispatch, isVisible, dismissed]); - - return { - wasDismissed: !isVisible, - dismiss, - }; -} - -export const DismissableBanner: React.FC> = ({ - id, - children, -}) => { +export const DismissableBanner: FC = ({ id, children }) => { const intl = useIntl(); - const { wasDismissed, dismiss } = useDismissableBannerState({ - id, - }); + const { wasDismissed, dismiss } = useDismissible(id); if (wasDismissed) { return null; diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx index 1c5cfeddcc..69795945a0 100644 --- a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -6,13 +6,13 @@ import classNames from 'classnames'; import Overlay from 'react-overlays/Overlay'; +import { useDismissible } from '@/mastodon/hooks/useDismissible'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { Button } from '../button'; -import { useDismissableBannerState } from '../dismissable_banner'; import { Icon } from '../icon'; -const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; +const DISMISSIBLE_BANNER_ID = 'notifications/remove_quote_hint'; /** * We don't want to show this hint in the UI more than once, @@ -29,9 +29,7 @@ export const RemoveQuoteHint: React.FC<{ const anchorRef = useRef(null); const intl = useIntl(); - const { wasDismissed, dismiss } = useDismissableBannerState({ - id: DISMISSABLE_BANNER_ID, - }); + const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID); const shouldShowHint = !wasDismissed && canShowHint; diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 78e6fbcf5f..049905dc08 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -8,8 +8,6 @@ import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; -import { AnnualReportTimeline } from 'mastodon/features/annual_report/timeline'; -import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import { StatusQuoteManager } from '../components/status_quoted'; @@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent { return ( ); - case TIMELINE_WRAPSTODON: - return ( - - ) case TIMELINE_GAP: return ( & { + reportState: AnnualReportAnnouncementProps['state']; }, -} satisfies Meta; + AnyFunction // Remove any functions, as they can't meaningfully be controlled in Storybook. +>; + +const meta = { + title: 'Components/AnnualReport/Announcement', + args: { + reportState: 'eligible', + year: '2025', + }, + argTypes: { + reportState: { + control: { + type: 'select', + }, + options: ['eligible', 'generating', 'available'], + }, + }, + render({ reportState, ...args }: Props) { + return ( + + ); + }, +} satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - render: (args) => , -}; +export const Default: Story = {}; export const Loading: Story = { args: { - isLoading: true, + reportState: 'generating', }, - render: Default.render, }; export const WithData: Story = { args: { - hasData: true, + reportState: 'available', }, - render: Default.render, }; diff --git a/app/javascript/mastodon/features/annual_report/announcement/index.tsx b/app/javascript/mastodon/features/annual_report/announcement/index.tsx index 67e1d7b3e5..283e95f594 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/index.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/index.tsx @@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; import { Button } from '@/mastodon/components/button'; import styles from './styles.module.scss'; -export const AnnualReportAnnouncement: React.FC<{ +export interface AnnualReportAnnouncementProps { year: string; - hasData: boolean; - isLoading: boolean; + state: Exclude; onRequestBuild: () => void; - onOpen: () => void; -}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { + onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then. + onDismiss: () => void; +} + +export const AnnualReportAnnouncement: React.FC< + AnnualReportAnnouncementProps +> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => { return (
-

- -

-

- -

- {hasData ? ( + + + {state === 'available' ? ( ) : ( - )} + {state === 'eligible' && ( + + )}
); }; diff --git a/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss b/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss index 1c3d033b2b..9ec62fa0fd 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss +++ b/app/javascript/mastodon/features/annual_report/announcement/styles.module.scss @@ -15,6 +15,8 @@ radial-gradient(at 16% 95%, #1e948299 0, transparent 50%) var(--color-bg-primary); border-bottom: 1px solid var(--color-border-primary); + position: relative; + pointer-events: all; h2 { font-size: 20px; @@ -26,4 +28,15 @@ p { margin-bottom: 20px; } + + .closeButton { + position: absolute; + bottom: 8px; + right: 8px; + margin-inline: 0; + } + + :global(.modal-root__modal) & { + border-radius: 16px; + } } diff --git a/app/javascript/mastodon/features/annual_report/archetype.tsx b/app/javascript/mastodon/features/annual_report/archetype.tsx index 660a1cf29d..465944df54 100644 --- a/app/javascript/mastodon/features/annual_report/archetype.tsx +++ b/app/javascript/mastodon/features/annual_report/archetype.tsx @@ -12,11 +12,13 @@ import replier from '@/images/archetypes/replier.png'; import space_elements from '@/images/archetypes/space_elements.png'; import { Avatar } from '@/mastodon/components/avatar'; import { Button } from '@/mastodon/components/button'; +import { me } from '@/mastodon/initial_state'; import type { Account } from '@/mastodon/models/account'; import type { AnnualReport, Archetype as ArchetypeData, } from '@/mastodon/models/annual_report'; +import { wrapstodonSettings } from '@/mastodon/settings'; import styles from './index.module.scss'; import { ShareButton } from './share_button'; @@ -117,9 +119,16 @@ export const Archetype: React.FC<{ const wrapperRef = useRef(null); const isSelfView = context === 'modal'; - const [isRevealed, setIsRevealed] = useState(!isSelfView); + const [isRevealed, setIsRevealed] = useState( + () => + !isSelfView || + (me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true), + ); const reveal = useCallback(() => { setIsRevealed(true); + if (me) { + wrapstodonSettings.set(me, { archetypeRevealed: true }); + } wrapperRef.current?.focus(); }, []); @@ -128,7 +137,8 @@ export const Archetype: React.FC<{ ? archetypeSelfDescriptions : archetypePublicDescriptions; - const name = account?.display_name; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty + const name = account?.display_name || account?.username; return (
state.accounts, (state) => state.annualReport.report], + (accounts, report) => { + if (me) { + return accounts.get(me); + } + if (report?.schema_version === 2) { + return accounts.get(report.account_id); + } + return undefined; + }, +); + export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ context = 'standalone', }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const report = useAppSelector((state) => state.annualReport.report); - const account = useAppSelector((state) => { - if (me) { - return state.accounts.get(me); - } - if (report?.schema_version === 2) { - return state.accounts.get(report.account_id); - } - return undefined; - }); + const account = useAppSelector(accountSelector); const close = useCallback(() => { dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); diff --git a/app/javascript/mastodon/features/annual_report/modal.tsx b/app/javascript/mastodon/features/annual_report/modal.tsx index c67a6ca19c..953732cba5 100644 --- a/app/javascript/mastodon/features/annual_report/modal.tsx +++ b/app/javascript/mastodon/features/annual_report/modal.tsx @@ -1,11 +1,17 @@ +import type { MouseEventHandler } from 'react'; import { useCallback, useEffect } from 'react'; import classNames from 'classnames'; import { closeModal } from '@/mastodon/actions/modal'; -import { useAppDispatch } from '@/mastodon/store'; +import { + generateReport, + selectWrapstodonYear, +} from '@/mastodon/reducers/slices/annual_report'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AnnualReport } from '.'; +import { AnnualReportAnnouncement } from './announcement'; import styles from './index.module.scss'; const AnnualReportModal: React.FC<{ @@ -15,17 +21,42 @@ const AnnualReportModal: React.FC<{ onChangeBackgroundColor('var(--color-bg-media-base)'); }, [onChangeBackgroundColor]); + const { state } = useAppSelector((state) => state.annualReport); + const year = useAppSelector(selectWrapstodonYear); + + const showAnnouncement = year && state && state !== 'available'; + const dispatch = useAppDispatch(); - const handleCloseModal = useCallback>( + + const handleBuildRequest = useCallback(() => { + void dispatch(generateReport()); + }, [dispatch]); + + const handleClose = useCallback(() => { + dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); + }, [dispatch]); + + const handleCloseModal: MouseEventHandler = useCallback( (e) => { - if (e.target === e.currentTarget) - dispatch( - closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }), - ); + if (e.target === e.currentTarget) { + handleClose(); + } }, - [dispatch], + [handleClose], ); + // Auto-close if ineligible + useEffect(() => { + if (state === 'ineligible') { + handleClose(); + } + }, [handleClose, state]); + + if (state === 'ineligible') { + // Not sure how you got here, but don't show anything. + return null; + } + return ( // It's fine not to provide a keyboard handler here since there is a global // [Esc] key listener that will close open modals. @@ -40,7 +71,16 @@ const AnnualReportModal: React.FC<{ )} onClick={handleCloseModal} > - + {!showAnnouncement ? ( + + ) : ( + + )}
); }; diff --git a/app/javascript/mastodon/features/annual_report/nav_item.tsx b/app/javascript/mastodon/features/annual_report/nav_item.tsx new file mode 100644 index 0000000000..bc293a7947 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/nav_item.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import IconPlanet from '@/images/icons/icon_planet.svg?react'; +import { openModal } from '@/mastodon/actions/modal'; +import { Icon } from '@/mastodon/components/icon'; +import { selectWrapstodonYear } from '@/mastodon/reducers/slices/annual_report'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/mastodon/store'; + +import classes from './index.module.scss'; + +const selectReportModalOpen = createAppSelector( + [(state) => state.modal.getIn(['stack', 0, 'modalType'])], + (modalType) => modalType === 'ANNUAL_REPORT', +); + +export const AnnualReportNavItem: FC = () => { + const { state } = useAppSelector((state) => state.annualReport); + const year = useAppSelector(selectWrapstodonYear); + const active = useAppSelector(selectReportModalOpen); + + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} })); + }, [dispatch]); + + if (!year || !state || state === 'ineligible') { + return null; + } + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/annual_report/share_button.tsx b/app/javascript/mastodon/features/annual_report/share_button.tsx index 80c2809adf..16dd834f4a 100644 --- a/app/javascript/mastodon/features/annual_report/share_button.tsx +++ b/app/javascript/mastodon/features/annual_report/share_button.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { showAlert } from '@/mastodon/actions/alerts'; import { resetCompose, focusCompose } from '@/mastodon/actions/compose'; import { closeModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; @@ -10,6 +11,7 @@ import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_ import { useAppDispatch } from '@/mastodon/store'; import { archetypeNames } from './archetype'; +import styles from './index.module.scss'; const messages = defineMessages({ share_message: { @@ -20,11 +22,24 @@ const messages = defineMessages({ id: 'annual_report.summary.share_on_mastodon', defaultMessage: 'Share on Mastodon', }, + share_elsewhere: { + id: 'annual_report.summary.share_elsewhere', + defaultMessage: 'Share elsewhere', + }, + copy_link: { + id: 'annual_report.summary.copy_link', + defaultMessage: 'Copy link', + }, + copied: { + id: 'copy_icon_button.copied', + defaultMessage: 'Copied to clipboard', + }, }); export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const handleShareClick = useCallback(() => { // Generate the share message. const archetypeName = intl.formatMessage( @@ -47,10 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); }, [report, intl, dispatch]); + const supportsNativeShare = 'share' in navigator; + + const handleSecondaryShare = useCallback(() => { + if (report.schema_version === 2 && report.share_url) { + if (supportsNativeShare) { + void navigator.share({ + url: report.share_url, + }); + } else { + void navigator.clipboard.writeText(report.share_url); + dispatch(showAlert({ message: messages.copied })); + } + } + }, [report, supportsNativeShare, dispatch]); + return ( -