mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-27 21:26:41 +00:00
Merge commit '4af8e83c8a236265c46a1b984fa5dbd3a7c73dfe' into glitch-soc/merge-upstream
This commit is contained in:
41
app/controllers/api/v1_alpha/collection_items_controller.rb
Normal file
41
app/controllers/api/v1_alpha/collection_items_controller.rb
Normal file
@@ -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
|
||||||
@@ -72,10 +72,13 @@ module SignatureVerification
|
|||||||
rescue Mastodon::SignatureVerificationError => e
|
rescue Mastodon::SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||||
|
@signature_verification_failure_code ||= 503
|
||||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||||
rescue Mastodon::UnexpectedResponseError
|
rescue Mastodon::UnexpectedResponseError
|
||||||
|
@signature_verification_failure_code ||= 503
|
||||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||||
rescue Stoplight::Error::RedLight
|
rescue Stoplight::Error::RedLight
|
||||||
|
@signature_verification_failure_code ||= 503
|
||||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
3
app/javascript/images/icons/icon_planet.svg
Normal file
3
app/javascript/images/icons/icon_planet.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
|
<path fill="currentColor" d="M9.2 8.525c.35 0 .646-.12.888-.363.241-.241.362-.537.362-.887s-.12-.646-.362-.888a1.207 1.207 0 0 0-.888-.362c-.35 0-.646.12-.887.362a1.207 1.207 0 0 0-.363.888c0 .35.12.646.362.887.242.242.538.363.888.363ZM18.525 20c-.7 0-1.642-.292-2.825-.875-1.183-.583-2.45-1.375-3.8-2.375a7.564 7.564 0 0 1-1.95.25C8 17 6.35 16.325 5 14.975c-1.35-1.35-2.025-3-2.025-4.95 0-.333.025-.667.075-1a9.18 9.18 0 0 1 .2-.975C2.267 6.7 1.48 5.437.888 4.262.296 3.089 0 2.15 0 1.45 0 1 .125.646.375.387.625.13.967 0 1.4 0c.433 0 .996.15 1.688.45.691.3 1.645.808 2.862 1.525-.35.183-.675.375-.975.575-.3.2-.592.417-.875.65a9.777 9.777 0 0 0-.925-.475c-.3-.133-.617-.275-.95-.425.3.633.62 1.25.962 1.85.342.6.705 1.192 1.088 1.775A6.775 6.775 0 0 1 6.7 3.8c.983-.517 2.067-.775 3.25-.775 1.95 0 3.604.68 4.963 2.038 1.358 1.358 2.037 3.012 2.037 4.962 0 1.183-.262 2.267-.787 3.25a6.89 6.89 0 0 1-2.138 2.425c.583.383 1.18.75 1.787 1.1.609.35 1.23.667 1.863.95-.133-.317-.27-.625-.413-.925-.141-.3-.304-.608-.487-.925.25-.283.475-.583.675-.9.2-.317.383-.642.55-.975.767 1.3 1.288 2.27 1.563 2.912.274.642.412 1.18.412 1.613 0 .483-.133.846-.4 1.087-.267.242-.617.363-1.05.363ZM11.7 13.025c.283 0 .52-.096.713-.287a.968.968 0 0 0 .287-.713.968.968 0 0 0-.287-.713.968.968 0 0 0-.713-.287.968.968 0 0 0-.712.287.968.968 0 0 0-.288.713c0 .283.096.52.288.713.191.191.429.287.712.287Zm1.25-3.5a.728.728 0 0 0 .75-.75.728.728 0 0 0-.75-.75.728.728 0 0 0-.75.75.728.728 0 0 0 .75.75Zm-3.275 5.45a45.451 45.451 0 0 1-2.45-2.275 39.166 39.166 0 0 1-2.25-2.45 4.944 4.944 0 0 0 1.45 3.275c.433.433.925.775 1.475 1.025.55.25 1.142.392 1.775.425Zm2.575-.525c.8-.417 1.45-1.02 1.95-1.813.5-.791.75-1.67.75-2.637 0-1.383-.487-2.558-1.462-3.525-.976-.967-2.155-1.45-3.538-1.45-.967 0-1.842.25-2.625.75a5.052 5.052 0 0 0-1.8 1.95 33.58 33.58 0 0 0 3.125 3.6 33.583 33.583 0 0 0 3.6 3.125Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -144,7 +144,7 @@ export const hydrateSearch = createAppAsyncThunk(
|
|||||||
'search/hydrate',
|
'search/hydrate',
|
||||||
(_args, { dispatch, getState }) => {
|
(_args, { dispatch, getState }) => {
|
||||||
const me = getState().meta.get('me') as string;
|
const me = getState().meta.get('me') as string;
|
||||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
const history = searchHistory.get(me);
|
||||||
|
|
||||||
if (history !== null) {
|
if (history !== null) {
|
||||||
dispatch(updateSearchHistory(history));
|
dispatch(updateSearchHistory(history));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
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 api, { getLinks } from 'mastodon/api';
|
||||||
import { compareId } from 'mastodon/compare_id';
|
import { compareId } from 'mastodon/compare_id';
|
||||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||||
@@ -32,7 +31,6 @@ export const TIMELINE_GAP = null;
|
|||||||
export const TIMELINE_NON_STATUS_MARKERS = [
|
export const TIMELINE_NON_STATUS_MARKERS = [
|
||||||
TIMELINE_GAP,
|
TIMELINE_GAP,
|
||||||
TIMELINE_SUGGESTIONS,
|
TIMELINE_SUGGESTIONS,
|
||||||
TIMELINE_WRAPSTODON,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loadPending = timeline => ({
|
export const loadPending = timeline => ({
|
||||||
@@ -132,7 +130,6 @@ export function expandTimeline(timelineId, path, params = {}) {
|
|||||||
|
|
||||||
if (timelineId === 'home') {
|
if (timelineId === 'home') {
|
||||||
dispatch(submitMarkers());
|
dispatch(submitMarkers());
|
||||||
dispatch(reinsertAnnualReport())
|
|
||||||
}
|
}
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { PropsWithChildren } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { useCallback, useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { changeSetting } from 'mastodon/actions/settings';
|
|
||||||
import { bannerSettings } from 'mastodon/settings';
|
import { useDismissible } from '../hooks/useDismissible';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
@@ -16,48 +14,12 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDismissableBannerState({ id }: Props) {
|
export const DismissableBanner: FC<Props> = ({ id, children }) => {
|
||||||
// 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<PropsWithChildren<Props>> = ({
|
|
||||||
id,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
const { wasDismissed, dismiss } = useDismissible(id);
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wasDismissed) {
|
if (wasDismissed) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import classNames from 'classnames';
|
|||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import { useDismissible } from '@/mastodon/hooks/useDismissible';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
import { useDismissableBannerState } from '../dismissable_banner';
|
|
||||||
import { Icon } from '../icon';
|
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,
|
* 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<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
const { wasDismissed, dismiss } = useDismissible(DISMISSIBLE_BANNER_ID);
|
||||||
id: DISMISSABLE_BANNER_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldShowHint = !wasDismissed && canShowHint;
|
const shouldShowHint = !wasDismissed && canShowHint;
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { debounce } from 'lodash';
|
|||||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||||
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
||||||
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
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';
|
import { StatusQuoteManager } from '../components/status_quoted';
|
||||||
|
|
||||||
@@ -67,10 +65,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
|
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
|
||||||
);
|
);
|
||||||
case TIMELINE_WRAPSTODON:
|
|
||||||
return (
|
|
||||||
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
|
|
||||||
)
|
|
||||||
case TIMELINE_GAP:
|
case TIMELINE_GAP:
|
||||||
return (
|
return (
|
||||||
<LoadGap
|
<LoadGap
|
||||||
|
|||||||
@@ -1,38 +1,60 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { fn } from 'storybook/test';
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
|
import type { AnyFunction, OmitValueType } from '@/mastodon/utils/types';
|
||||||
|
|
||||||
|
import type { AnnualReportAnnouncementProps } from '.';
|
||||||
import { AnnualReportAnnouncement } from '.';
|
import { AnnualReportAnnouncement } from '.';
|
||||||
|
|
||||||
const meta = {
|
type Props = OmitValueType<
|
||||||
title: 'Components/AnnualReportAnnouncement',
|
// We can't use the name 'state' here because it's reserved for overriding Redux state.
|
||||||
component: AnnualReportAnnouncement,
|
Omit<AnnualReportAnnouncementProps, 'state'> & {
|
||||||
args: {
|
reportState: AnnualReportAnnouncementProps['state'];
|
||||||
hasData: false,
|
|
||||||
isLoading: false,
|
|
||||||
year: '2025',
|
|
||||||
onRequestBuild: fn(),
|
|
||||||
onOpen: fn(),
|
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof AnnualReportAnnouncement>;
|
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 (
|
||||||
|
<AnnualReportAnnouncement
|
||||||
|
state={reportState}
|
||||||
|
{...args}
|
||||||
|
onDismiss={action('dismissed announcement')}
|
||||||
|
onOpen={action('opened report modal')}
|
||||||
|
onRequestBuild={action('requested build')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<Props>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {};
|
||||||
render: (args) => <AnnualReportAnnouncement {...args} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
isLoading: true,
|
reportState: 'generating',
|
||||||
},
|
},
|
||||||
render: Default.render,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithData: Story = {
|
export const WithData: Story = {
|
||||||
args: {
|
args: {
|
||||||
hasData: true,
|
reportState: 'available',
|
||||||
},
|
},
|
||||||
render: Default.render,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,33 +2,36 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
export const AnnualReportAnnouncement: React.FC<{
|
export interface AnnualReportAnnouncementProps {
|
||||||
year: string;
|
year: string;
|
||||||
hasData: boolean;
|
state: Exclude<ApiAnnualReportState, 'ineligible'>;
|
||||||
isLoading: boolean;
|
|
||||||
onRequestBuild: () => void;
|
onRequestBuild: () => void;
|
||||||
onOpen: () => void;
|
onOpen?: () => void; // This is optional when inside the modal, as it won't be shown then.
|
||||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnnualReportAnnouncement: React.FC<
|
||||||
|
AnnualReportAnnouncementProps
|
||||||
|
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||||
<h2>
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='annual_report.announcement.title'
|
||||||
id='annual_report.announcement.title'
|
defaultMessage='Wrapstodon {year} has arrived'
|
||||||
defaultMessage='Wrapstodon {year} has arrived'
|
values={{ year }}
|
||||||
values={{ year }}
|
tagName='h2'
|
||||||
/>
|
/>
|
||||||
</h2>
|
<FormattedMessage
|
||||||
<p>
|
id='annual_report.announcement.description'
|
||||||
<FormattedMessage
|
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
||||||
id='annual_report.announcement.description'
|
tagName='p'
|
||||||
defaultMessage='Discover more about your engagement on Mastodon over the past year.'
|
/>
|
||||||
/>
|
{state === 'available' ? (
|
||||||
</p>
|
|
||||||
{hasData ? (
|
|
||||||
<Button onClick={onOpen}>
|
<Button onClick={onOpen}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.announcement.action_view'
|
id='annual_report.announcement.action_view'
|
||||||
@@ -36,13 +39,21 @@ export const AnnualReportAnnouncement: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button loading={isLoading} onClick={onRequestBuild}>
|
<Button loading={state === 'generating'} onClick={onRequestBuild}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.announcement.action_build'
|
id='annual_report.announcement.action_build'
|
||||||
defaultMessage='Build my Wrapstodon'
|
defaultMessage='Build my Wrapstodon'
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{state === 'eligible' && (
|
||||||
|
<Button onClick={onDismiss} plain className={styles.closeButton}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='annual_report.announcement.action_dismiss'
|
||||||
|
defaultMessage='No thanks'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
|
radial-gradient(at 16% 95%, #1e948299 0, transparent 50%)
|
||||||
var(--color-bg-primary);
|
var(--color-bg-primary);
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
position: relative;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -26,4 +28,15 @@
|
|||||||
p {
|
p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.modal-root__modal) & {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import replier from '@/images/archetypes/replier.png';
|
|||||||
import space_elements from '@/images/archetypes/space_elements.png';
|
import space_elements from '@/images/archetypes/space_elements.png';
|
||||||
import { Avatar } from '@/mastodon/components/avatar';
|
import { Avatar } from '@/mastodon/components/avatar';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
|
import { me } from '@/mastodon/initial_state';
|
||||||
import type { Account } from '@/mastodon/models/account';
|
import type { Account } from '@/mastodon/models/account';
|
||||||
import type {
|
import type {
|
||||||
AnnualReport,
|
AnnualReport,
|
||||||
Archetype as ArchetypeData,
|
Archetype as ArchetypeData,
|
||||||
} from '@/mastodon/models/annual_report';
|
} from '@/mastodon/models/annual_report';
|
||||||
|
import { wrapstodonSettings } from '@/mastodon/settings';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import { ShareButton } from './share_button';
|
import { ShareButton } from './share_button';
|
||||||
@@ -117,9 +119,16 @@ export const Archetype: React.FC<{
|
|||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const isSelfView = context === 'modal';
|
const isSelfView = context === 'modal';
|
||||||
|
|
||||||
const [isRevealed, setIsRevealed] = useState(!isSelfView);
|
const [isRevealed, setIsRevealed] = useState(
|
||||||
|
() =>
|
||||||
|
!isSelfView ||
|
||||||
|
(me ? (wrapstodonSettings.get(me)?.archetypeRevealed ?? false) : true),
|
||||||
|
);
|
||||||
const reveal = useCallback(() => {
|
const reveal = useCallback(() => {
|
||||||
setIsRevealed(true);
|
setIsRevealed(true);
|
||||||
|
if (me) {
|
||||||
|
wrapstodonSettings.set(me, { archetypeRevealed: true });
|
||||||
|
}
|
||||||
wrapperRef.current?.focus();
|
wrapperRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -128,7 +137,8 @@ export const Archetype: React.FC<{
|
|||||||
? archetypeSelfDescriptions
|
? archetypeSelfDescriptions
|
||||||
: archetypePublicDescriptions;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ $mobile-breakpoint: 540px;
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #c2c8ff;
|
color: var(--color-text-brand-soft);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@@ -332,3 +332,20 @@ $mobile-breakpoint: 540px;
|
|||||||
left: 0;
|
left: 0;
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shareButtonWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryShareButton {
|
||||||
|
// Extra selector is needed to override color
|
||||||
|
&:global(.button) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItemBadge {
|
||||||
|
background: var(--color-bg-brand-soft);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { closeModal } from '@/mastodon/actions/modal';
|
|||||||
import { IconButton } from '@/mastodon/components/icon_button';
|
import { IconButton } from '@/mastodon/components/icon_button';
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
import { me } from '@/mastodon/initial_state';
|
import { me } from '@/mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import {
|
||||||
|
createAppSelector,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from '@/mastodon/store';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import { Archetype } from './archetype';
|
import { Archetype } from './archetype';
|
||||||
@@ -23,21 +27,26 @@ import { NewPosts } from './new_posts';
|
|||||||
|
|
||||||
const moduleClassNames = classNames.bind(styles);
|
const moduleClassNames = classNames.bind(styles);
|
||||||
|
|
||||||
|
const accountSelector = createAppSelector(
|
||||||
|
[(state) => 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' }> = ({
|
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||||
context = 'standalone',
|
context = 'standalone',
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const report = useAppSelector((state) => state.annualReport.report);
|
const report = useAppSelector((state) => state.annualReport.report);
|
||||||
const account = useAppSelector((state) => {
|
const account = useAppSelector(accountSelector);
|
||||||
if (me) {
|
|
||||||
return state.accounts.get(me);
|
|
||||||
}
|
|
||||||
if (report?.schema_version === 2) {
|
|
||||||
return state.accounts.get(report.account_id);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { closeModal } from '@/mastodon/actions/modal';
|
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 { AnnualReport } from '.';
|
||||||
|
import { AnnualReportAnnouncement } from './announcement';
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
const AnnualReportModal: React.FC<{
|
const AnnualReportModal: React.FC<{
|
||||||
@@ -15,17 +21,42 @@ const AnnualReportModal: React.FC<{
|
|||||||
onChangeBackgroundColor('var(--color-bg-media-base)');
|
onChangeBackgroundColor('var(--color-bg-media-base)');
|
||||||
}, [onChangeBackgroundColor]);
|
}, [onChangeBackgroundColor]);
|
||||||
|
|
||||||
|
const { state } = useAppSelector((state) => state.annualReport);
|
||||||
|
const year = useAppSelector(selectWrapstodonYear);
|
||||||
|
|
||||||
|
const showAnnouncement = year && state && state !== 'available';
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleCloseModal = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
|
||||||
|
const handleBuildRequest = useCallback(() => {
|
||||||
|
void dispatch(generateReport());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleCloseModal: MouseEventHandler = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
if (e.target === e.currentTarget)
|
if (e.target === e.currentTarget) {
|
||||||
dispatch(
|
handleClose();
|
||||||
closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }),
|
}
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[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 (
|
return (
|
||||||
// It's fine not to provide a keyboard handler here since there is a global
|
// It's fine not to provide a keyboard handler here since there is a global
|
||||||
// [Esc] key listener that will close open modals.
|
// [Esc] key listener that will close open modals.
|
||||||
@@ -40,7 +71,16 @@ const AnnualReportModal: React.FC<{
|
|||||||
)}
|
)}
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
>
|
>
|
||||||
<AnnualReport context='modal' />
|
{!showAnnouncement ? (
|
||||||
|
<AnnualReport context='modal' />
|
||||||
|
) : (
|
||||||
|
<AnnualReportAnnouncement
|
||||||
|
year={year.toString()}
|
||||||
|
state={state}
|
||||||
|
onDismiss={handleClose}
|
||||||
|
onRequestBuild={handleBuildRequest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
55
app/javascript/mastodon/features/annual_report/nav_item.tsx
Normal file
55
app/javascript/mastodon/features/annual_report/nav_item.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={classNames('column-link column-link--transparent', { active })}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Icon icon={IconPlanet} id='wrapstodon-planet' width='24' height='24' />
|
||||||
|
<span>Wrapstodon {year}</span>
|
||||||
|
<span className={classNames('column-link__badge', classes.navItemBadge)}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='annual_report.nav_item.badge'
|
||||||
|
defaultMessage='New'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { showAlert } from '@/mastodon/actions/alerts';
|
||||||
import { resetCompose, focusCompose } from '@/mastodon/actions/compose';
|
import { resetCompose, focusCompose } from '@/mastodon/actions/compose';
|
||||||
import { closeModal } from '@/mastodon/actions/modal';
|
import { closeModal } from '@/mastodon/actions/modal';
|
||||||
import { Button } from '@/mastodon/components/button';
|
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 { useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
import { archetypeNames } from './archetype';
|
import { archetypeNames } from './archetype';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
share_message: {
|
share_message: {
|
||||||
@@ -20,11 +22,24 @@ const messages = defineMessages({
|
|||||||
id: 'annual_report.summary.share_on_mastodon',
|
id: 'annual_report.summary.share_on_mastodon',
|
||||||
defaultMessage: '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 }) => {
|
export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleShareClick = useCallback(() => {
|
const handleShareClick = useCallback(() => {
|
||||||
// Generate the share message.
|
// Generate the share message.
|
||||||
const archetypeName = intl.formatMessage(
|
const archetypeName = intl.formatMessage(
|
||||||
@@ -47,10 +62,35 @@ export const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => {
|
|||||||
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
|
||||||
}, [report, intl, dispatch]);
|
}, [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 (
|
return (
|
||||||
<Button
|
<div className={styles.shareButtonWrapper}>
|
||||||
text={intl.formatMessage(messages.share_on_mastodon)}
|
<Button
|
||||||
onClick={handleShareClick}
|
text={intl.formatMessage(messages.share_on_mastodon)}
|
||||||
/>
|
onClick={handleShareClick}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
plain
|
||||||
|
className={styles.secondaryShareButton}
|
||||||
|
text={intl.formatMessage(
|
||||||
|
supportsNativeShare ? messages.share_elsewhere : messages.copy_link,
|
||||||
|
)}
|
||||||
|
onClick={handleSecondaryShare}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,14 +17,11 @@ export const WrapstodonSharedPage: FC = () => {
|
|||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.shared_page.footer'
|
id='annual_report.shared_page.footer'
|
||||||
defaultMessage='Generated with {heart} by the Mastodon team'
|
defaultMessage='Generated with {heart} by the Mastodon team'
|
||||||
values={{ heart: '♥' }}
|
values={{ heart: '🐘' }}
|
||||||
/>
|
/>
|
||||||
<nav className={classes.nav}>
|
<nav className={classes.nav}>
|
||||||
<a href='/about'>
|
<a href='https://joinmastodon.org'>
|
||||||
<FormattedMessage
|
<FormattedMessage id='footer.about' defaultMessage='About' />
|
||||||
id='footer.about_this_server'
|
|
||||||
defaultMessage='About'
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
{!me && (
|
{!me && (
|
||||||
<a href='https://joinmastodon.org/servers'>
|
<a href='https://joinmastodon.org/servers'>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { openModal } from '@/mastodon/actions/modal';
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
|
import { useDismissible } from '@/mastodon/hooks/useDismissible';
|
||||||
import {
|
import {
|
||||||
generateReport,
|
generateReport,
|
||||||
selectWrapstodonYear,
|
selectWrapstodonYear,
|
||||||
@@ -19,21 +20,26 @@ export const AnnualReportTimeline: FC = () => {
|
|||||||
void dispatch(generateReport());
|
void dispatch(generateReport());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const { wasDismissed, dismiss } = useDismissible(
|
||||||
|
`annual_report_announcement_${year}`,
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
|
dispatch(openModal({ modalType: 'ANNUAL_REPORT', modalProps: {} }));
|
||||||
}, [dispatch]);
|
dismiss();
|
||||||
|
}, [dismiss, dispatch]);
|
||||||
|
|
||||||
if (!year || !state || state === 'ineligible') {
|
if (!year || wasDismissed || !state || state === 'ineligible') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnnualReportAnnouncement
|
<AnnualReportAnnouncement
|
||||||
year={year.toString()}
|
year={year.toString()}
|
||||||
hasData={state === 'available'}
|
state={state}
|
||||||
isLoading={state === 'generating'}
|
|
||||||
onRequestBuild={handleBuildRequest}
|
onRequestBuild={handleBuildRequest}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
|
onDismiss={dismiss}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreVolume = (audio: HTMLAudioElement) => {
|
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
const volume = playerSettings.get('volume') ?? 0.5;
|
||||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
const muted = playerSettings.get('muted') ?? false;
|
||||||
|
|
||||||
audio.volume = volume;
|
audio.volume = volume;
|
||||||
audio.muted = muted;
|
audio.muted = muted;
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export const CriticalUpdateBanner = () => (
|
import { criticalUpdatesPending } from '@/mastodon/initial_state';
|
||||||
<div className='warning-banner'>
|
|
||||||
<div className='warning-banner__message'>
|
export const CriticalUpdateBanner: FC = () => {
|
||||||
<h1>
|
if (!criticalUpdatesPending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className='warning-banner'>
|
||||||
|
<div className='warning-banner__message'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='home.pending_critical_update.title'
|
id='home.pending_critical_update.title'
|
||||||
defaultMessage='Critical security update available!'
|
defaultMessage='Critical security update available!'
|
||||||
|
tagName='h1'
|
||||||
/>
|
/>
|
||||||
</h1>
|
<p>
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id='home.pending_critical_update.body'
|
|
||||||
defaultMessage='Please update your Mastodon server as soon as possible!'
|
|
||||||
/>{' '}
|
|
||||||
<a href='/admin/software_updates'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='home.pending_critical_update.link'
|
id='home.pending_critical_update.body'
|
||||||
defaultMessage='See updates'
|
defaultMessage='Please update your Mastodon server as soon as possible!'
|
||||||
/>
|
/>{' '}
|
||||||
</a>
|
<a href='/admin/software_updates'>
|
||||||
</p>
|
<FormattedMessage
|
||||||
|
id='home.pending_critical_update.link'
|
||||||
|
defaultMessage='See updates'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
|
|||||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
|
||||||
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||||
|
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
@@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||||||
import { ColumnSettings } from './components/column_settings';
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||||
import { Announcements } from './components/announcements';
|
import { Announcements } from './components/announcements';
|
||||||
|
import { AnnualReportTimeline } from '../annual_report/timeline';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
@@ -127,7 +127,10 @@ class HomeTimeline extends PureComponent {
|
|||||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
const { signedIn } = this.props.identity;
|
const { signedIn } = this.props.identity;
|
||||||
const banners = [];
|
const banners = [
|
||||||
|
<CriticalUpdateBanner key='critical-update-banner' />,
|
||||||
|
<AnnualReportTimeline key='annual-report' />
|
||||||
|
];
|
||||||
|
|
||||||
let announcementsButton;
|
let announcementsButton;
|
||||||
|
|
||||||
@@ -145,10 +148,6 @@ class HomeTimeline extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criticalUpdatesPending) {
|
|
||||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import { canViewFeed } from 'mastodon/permissions';
|
|||||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||||
|
|
||||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||||
import { ListPanel } from './components/list_panel';
|
import { ListPanel } from './components/list_panel';
|
||||||
@@ -294,6 +296,8 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
|||||||
|
|
||||||
<FollowRequestsLink />
|
<FollowRequestsLink />
|
||||||
|
|
||||||
|
<AnnualReportNavItem />
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<ListPanel />
|
<ListPanel />
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ const persistVolume = (volume: number, muted: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreVolume = (video: HTMLVideoElement) => {
|
const restoreVolume = (video: HTMLVideoElement) => {
|
||||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
const volume = playerSettings.get('volume') ?? 0.5;
|
||||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
const muted = playerSettings.get('muted') ?? false;
|
||||||
|
|
||||||
video.volume = volume;
|
video.volume = volume;
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
|
|||||||
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal file
42
app/javascript/mastodon/hooks/useDismissible.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import { changeSetting } from '@/mastodon/actions/settings';
|
||||||
|
import { bannerSettings } from '@/mastodon/settings';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
|
export function useDismissible(id: string) {
|
||||||
|
// We use "dismissed_banners" as that was what this was previously called,
|
||||||
|
// but we can use this to track any dismissible state.
|
||||||
|
const dismissed = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
!!(
|
||||||
|
state.settings as ImmutableMap<
|
||||||
|
'dismissed_banners',
|
||||||
|
ImmutableMap<string, boolean>
|
||||||
|
>
|
||||||
|
).getIn(['dismissed_banners', id], false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wasDismissed = !!bannerSettings.get(id) || dismissed;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
bannerSettings.set(id, true);
|
||||||
|
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||||
|
}, [id, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Store legacy localStorage setting on server
|
||||||
|
if (wasDismissed && !dismissed) {
|
||||||
|
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||||
|
}
|
||||||
|
}, [id, dispatch, wasDismissed, dismissed]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wasDismissed,
|
||||||
|
dismiss,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -114,9 +114,11 @@
|
|||||||
"alt_text_modal.done": "Done",
|
"alt_text_modal.done": "Done",
|
||||||
"announcement.announcement": "Announcement",
|
"announcement.announcement": "Announcement",
|
||||||
"annual_report.announcement.action_build": "Build my Wrapstodon",
|
"annual_report.announcement.action_build": "Build my Wrapstodon",
|
||||||
|
"annual_report.announcement.action_dismiss": "No thanks",
|
||||||
"annual_report.announcement.action_view": "View my Wrapstodon",
|
"annual_report.announcement.action_view": "View my Wrapstodon",
|
||||||
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
|
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
|
||||||
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
|
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
|
||||||
|
"annual_report.nav_item.badge": "New",
|
||||||
"annual_report.shared_page.donate": "Donate",
|
"annual_report.shared_page.donate": "Donate",
|
||||||
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
|
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
|
||||||
"annual_report.shared_page.sign_up": "Sign up",
|
"annual_report.shared_page.sign_up": "Sign up",
|
||||||
@@ -141,6 +143,7 @@
|
|||||||
"annual_report.summary.archetype.title_public": "{name}'s archetype",
|
"annual_report.summary.archetype.title_public": "{name}'s archetype",
|
||||||
"annual_report.summary.archetype.title_self": "Your archetype",
|
"annual_report.summary.archetype.title_self": "Your archetype",
|
||||||
"annual_report.summary.close": "Close",
|
"annual_report.summary.close": "Close",
|
||||||
|
"annual_report.summary.copy_link": "Copy link",
|
||||||
"annual_report.summary.followers.new_followers": "{count, plural, one {new follower} other {new followers}}",
|
"annual_report.summary.followers.new_followers": "{count, plural, one {new follower} other {new followers}}",
|
||||||
"annual_report.summary.highlighted_post.boost_count": "This post was boosted {count, plural, one {once} other {# times}}.",
|
"annual_report.summary.highlighted_post.boost_count": "This post was boosted {count, plural, one {once} other {# times}}.",
|
||||||
"annual_report.summary.highlighted_post.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.",
|
"annual_report.summary.highlighted_post.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.",
|
||||||
@@ -153,6 +156,7 @@
|
|||||||
"annual_report.summary.new_posts.new_posts": "new posts",
|
"annual_report.summary.new_posts.new_posts": "new posts",
|
||||||
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
|
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
|
||||||
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
|
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
|
||||||
|
"annual_report.summary.share_elsewhere": "Share elsewhere",
|
||||||
"annual_report.summary.share_message": "I got the {archetype} archetype!",
|
"annual_report.summary.share_message": "I got the {archetype} archetype!",
|
||||||
"annual_report.summary.share_on_mastodon": "Share on Mastodon",
|
"annual_report.summary.share_on_mastodon": "Share on Mastodon",
|
||||||
"attachments_list.unprocessed": "(unprocessed)",
|
"attachments_list.unprocessed": "(unprocessed)",
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
importFetchedAccounts,
|
importFetchedAccounts,
|
||||||
importFetchedStatuses,
|
importFetchedStatuses,
|
||||||
} from '@/mastodon/actions/importer';
|
} from '@/mastodon/actions/importer';
|
||||||
import { insertIntoTimeline } from '@/mastodon/actions/timelines';
|
|
||||||
import { timelineDelete } from '@/mastodon/actions/timelines_typed';
|
|
||||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||||
import {
|
import {
|
||||||
apiGetAnnualReport,
|
apiGetAnnualReport,
|
||||||
@@ -21,8 +19,6 @@ import {
|
|||||||
createDataLoadingThunk,
|
createDataLoadingThunk,
|
||||||
} from '../../store/typed_functions';
|
} from '../../store/typed_functions';
|
||||||
|
|
||||||
export const TIMELINE_WRAPSTODON = 'inline-wrapstodon';
|
|
||||||
|
|
||||||
interface AnnualReportState {
|
interface AnnualReportState {
|
||||||
state?: ApiAnnualReportState;
|
state?: ApiAnnualReportState;
|
||||||
report?: AnnualReport;
|
report?: AnnualReport;
|
||||||
@@ -64,37 +60,12 @@ export const selectWrapstodonYear = createAppSelector(
|
|||||||
// This kicks everything off, and is called after fetching the server info.
|
// This kicks everything off, and is called after fetching the server info.
|
||||||
export const checkAnnualReport = createAppThunk(
|
export const checkAnnualReport = createAppThunk(
|
||||||
`${annualReportSlice.name}/checkAnnualReport`,
|
`${annualReportSlice.name}/checkAnnualReport`,
|
||||||
async (_arg: unknown, { dispatch, getState }) => {
|
(_arg: unknown, { dispatch, getState }) => {
|
||||||
const year = selectWrapstodonYear(getState());
|
const year = selectWrapstodonYear(getState());
|
||||||
if (!year) {
|
if (!year) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const state = await dispatch(fetchReportState());
|
void dispatch(fetchReportState());
|
||||||
if (
|
|
||||||
state.meta.requestStatus === 'fulfilled' &&
|
|
||||||
state.payload !== 'ineligible'
|
|
||||||
) {
|
|
||||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const reinsertAnnualReport = createAppThunk(
|
|
||||||
`${annualReportSlice.name}/reinsertAnnualReport`,
|
|
||||||
(_arg: unknown, { dispatch, getState }) => {
|
|
||||||
dispatch(
|
|
||||||
timelineDelete({
|
|
||||||
statusId: TIMELINE_WRAPSTODON,
|
|
||||||
accountId: '',
|
|
||||||
references: [],
|
|
||||||
reblogOf: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const { state } = getState().annualReport;
|
|
||||||
if (!state || state === 'ineligible') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
export default class Settings {
|
|
||||||
|
|
||||||
constructor(keyBase = null) {
|
|
||||||
this.keyBase = keyBase;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateKey(id) {
|
|
||||||
return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(id, data) {
|
|
||||||
const key = this.generateKey(id);
|
|
||||||
try {
|
|
||||||
const encodedData = JSON.stringify(data);
|
|
||||||
localStorage.setItem(key, encodedData);
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get(id) {
|
|
||||||
const key = this.generateKey(id);
|
|
||||||
try {
|
|
||||||
const rawData = localStorage.getItem(key);
|
|
||||||
return JSON.parse(rawData);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(id) {
|
|
||||||
const data = this.get(id);
|
|
||||||
if (data) {
|
|
||||||
const key = this.generateKey(id);
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// ignore if the key is not found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
|
|
||||||
export const tagHistory = new Settings('mastodon_tag_history');
|
|
||||||
export const bannerSettings = new Settings('mastodon_banner_settings');
|
|
||||||
export const searchHistory = new Settings('mastodon_search_history');
|
|
||||||
export const playerSettings = new Settings('mastodon_player');
|
|
||||||
68
app/javascript/mastodon/settings.ts
Normal file
68
app/javascript/mastodon/settings.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { RecentSearch } from './models/search';
|
||||||
|
|
||||||
|
export class Settings<T extends Record<string, unknown>> {
|
||||||
|
keyBase: string | null;
|
||||||
|
|
||||||
|
constructor(keyBase: string | null = null) {
|
||||||
|
this.keyBase = keyBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateKey(id: string | number | symbol): string {
|
||||||
|
const idStr = typeof id === 'string' ? id : String(id);
|
||||||
|
return this.keyBase ? [this.keyBase, `id${idStr}`].join('.') : idStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof T>(id: K, data: T[K]): T[K] | null {
|
||||||
|
const key = this.generateKey(id);
|
||||||
|
try {
|
||||||
|
const encodedData = JSON.stringify(data);
|
||||||
|
localStorage.setItem(key, encodedData);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get<K extends keyof T>(id: K): T[K] | null {
|
||||||
|
const key = this.generateKey(id);
|
||||||
|
try {
|
||||||
|
const rawData = localStorage.getItem(key);
|
||||||
|
if (rawData === null) return null;
|
||||||
|
return JSON.parse(rawData) as T[K];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove<K extends keyof T>(id: K): T[K] | null {
|
||||||
|
const data = this.get(id);
|
||||||
|
if (data !== null) {
|
||||||
|
const key = this.generateKey(id);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// ignore if the key is not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushNotificationsSetting = new Settings<
|
||||||
|
Record<string, { alerts: unknown }>
|
||||||
|
>('mastodon_push_notification_data');
|
||||||
|
export const tagHistory = new Settings<Record<string, string[]>>(
|
||||||
|
'mastodon_tag_history',
|
||||||
|
);
|
||||||
|
export const bannerSettings = new Settings<Record<string, boolean>>(
|
||||||
|
'mastodon_banner_settings',
|
||||||
|
);
|
||||||
|
export const searchHistory = new Settings<Record<string, RecentSearch[]>>(
|
||||||
|
'mastodon_search_history',
|
||||||
|
);
|
||||||
|
export const playerSettings = new Settings<{ volume: number; muted: boolean }>(
|
||||||
|
'mastodon_player',
|
||||||
|
);
|
||||||
|
export const wrapstodonSettings = new Settings<
|
||||||
|
Record<string, { archetypeRevealed: boolean }>
|
||||||
|
>('wrapstodon');
|
||||||
@@ -14,3 +14,9 @@
|
|||||||
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||||
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
|
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
|
||||||
Partial<Pick<T, K>>;
|
Partial<Pick<T, K>>;
|
||||||
|
|
||||||
|
export type OmitValueType<T, V> = {
|
||||||
|
[K in keyof T as T[K] extends V ? never : K]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnyFunction = (...args: never) => unknown;
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
class REST::CollectionItemSerializer < ActiveModel::Serializer
|
class REST::CollectionItemSerializer < ActiveModel::Serializer
|
||||||
delegate :accepted?, to: :object
|
delegate :accepted?, to: :object
|
||||||
|
|
||||||
attributes :position, :state
|
attributes :id, :position, :state
|
||||||
|
|
||||||
belongs_to :account, serializer: REST::AccountSerializer, if: :accepted?
|
belongs_to :account, serializer: REST::AccountSerializer, if: :accepted?
|
||||||
|
|
||||||
|
def id
|
||||||
|
object.id.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ namespace :api, format: false do
|
|||||||
|
|
||||||
resources :async_refreshes, only: :show
|
resources :async_refreshes, only: :show
|
||||||
|
|
||||||
resources :collections, only: [:show, :create, :update, :destroy]
|
resources :collections, only: [:show, :create, :update, :destroy] do
|
||||||
|
resources :items, only: [:create], controller: 'collection_items'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# JSON / REST API
|
# JSON / REST API
|
||||||
|
|||||||
55
spec/requests/api/v1_alpha/collection_items_spec.rb
Normal file
55
spec/requests/api/v1_alpha/collection_items_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
|
||||||
|
include_context 'with API authentication', oauth_scopes: 'read:collections write:collections'
|
||||||
|
|
||||||
|
describe 'POST /api/v1_alpha/collections/:collection_id/items' do
|
||||||
|
subject do
|
||||||
|
post "/api/v1_alpha/collections/#{collection.id}/items", headers: headers, params: params
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:collection) { Fabricate(:collection, account: user.account) }
|
||||||
|
let(:params) { {} }
|
||||||
|
|
||||||
|
it_behaves_like 'forbidden for wrong scope', 'read'
|
||||||
|
|
||||||
|
context 'when user is owner of the collection' do
|
||||||
|
context 'with valid params' do
|
||||||
|
let(:other_account) { Fabricate(:account) }
|
||||||
|
let(:params) { { account_id: other_account.id } }
|
||||||
|
|
||||||
|
it 'creates a collection item and returns http success' do
|
||||||
|
expect do
|
||||||
|
subject
|
||||||
|
end.to change(collection.collection_items, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid params' do
|
||||||
|
it 'returns http unprocessable content' do
|
||||||
|
expect do
|
||||||
|
subject
|
||||||
|
end.to_not change(CollectionItem, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(422)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not the owner of the collection' do
|
||||||
|
let(:collection) { Fabricate(:collection) }
|
||||||
|
let(:other_account) { Fabricate(:account) }
|
||||||
|
let(:params) { { account_id: other_account.id } }
|
||||||
|
|
||||||
|
it 'returns http forbidden' do
|
||||||
|
subject
|
||||||
|
|
||||||
|
expect(response).to have_http_status(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ RSpec.describe REST::CollectionItemSerializer do
|
|||||||
|
|
||||||
let(:collection_item) do
|
let(:collection_item) do
|
||||||
Fabricate(:collection_item,
|
Fabricate(:collection_item,
|
||||||
|
id: 2342,
|
||||||
state:,
|
state:,
|
||||||
position: 4)
|
position: 4)
|
||||||
end
|
end
|
||||||
@@ -17,6 +18,7 @@ RSpec.describe REST::CollectionItemSerializer do
|
|||||||
it 'includes the relevant attributes including the account' do
|
it 'includes the relevant attributes including the account' do
|
||||||
expect(subject)
|
expect(subject)
|
||||||
.to include(
|
.to include(
|
||||||
|
'id' => '2342',
|
||||||
'account' => an_instance_of(Hash),
|
'account' => an_instance_of(Hash),
|
||||||
'state' => 'accepted',
|
'state' => 'accepted',
|
||||||
'position' => 4
|
'position' => 4
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
'files': ['app/javascript/styles/entrypoints/mailer.scss'],
|
files: ['app/javascript/styles/entrypoints/mailer.scss'],
|
||||||
rules: {
|
rules: {
|
||||||
'property-no-unknown': [
|
'property-no-unknown': [
|
||||||
true,
|
true,
|
||||||
@@ -44,5 +44,14 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['app/javascript/**/*.module.scss'],
|
||||||
|
rules: {
|
||||||
|
'selector-pseudo-class-no-unknown': [
|
||||||
|
true,
|
||||||
|
{ ignorePseudoClasses: ['global'] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user