diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e1b8ebf38d..1076d9ced8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -155,6 +155,7 @@ module ApplicationHelper def html_classes output = [] + output << content_for(:html_classes) output << 'system-font' if current_account&.user&.setting_system_font_ui output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion') diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx index d599d30e67..7a74e18d52 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -6,7 +6,7 @@ import { importFetchedStatuses } from '@/mastodon/actions/importer'; import { hydrateStore } from '@/mastodon/actions/store'; import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report'; import { Router } from '@/mastodon/components/router'; -import { WrapstodonShare } from '@/mastodon/features/annual_report/share'; +import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page'; import { IntlProvider, loadLocale } from '@/mastodon/locales'; import { loadPolyfills } from '@/mastodon/polyfills'; import ready from '@/mastodon/ready'; @@ -48,7 +48,7 @@ function loaded() { - + , diff --git a/app/javascript/images/archetypes/space_elements.png b/app/javascript/images/archetypes/space_elements.png new file mode 100644 index 0000000000..8b83506b8e Binary files /dev/null and b/app/javascript/images/archetypes/space_elements.png differ diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 15f0b9da30..892270b394 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -117,6 +117,7 @@ class Status extends ImmutablePureComponent { hidden: PropTypes.bool, unread: PropTypes.bool, showThread: PropTypes.bool, + showActions: PropTypes.bool, isQuotedPost: PropTypes.bool, shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, @@ -381,7 +382,7 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props; + const { intl, hidden, featured, unfocusable, unread, showThread, showActions = true, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props; let { status, account, ...other } = this.props; @@ -618,7 +619,7 @@ class Status extends ImmutablePureComponent { )} - {!isQuotedPost && + {(showActions && !isQuotedPost) && } diff --git a/app/javascript/mastodon/features/annual_report/announcement/index.tsx b/app/javascript/mastodon/features/annual_report/announcement/index.tsx index 7cdb36e35f..67e1d7b3e5 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/index.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/index.tsx @@ -1,5 +1,7 @@ import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; + import { Button } from '@/mastodon/components/button'; import styles from './styles.module.scss'; @@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{ onOpen: () => void; }> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => { return ( -
+

({ booster: { - id: 'annual_report.summary.archetype.booster', - defaultMessage: 'The cool-hunter', + id: 'annual_report.summary.archetype.booster.name', + defaultMessage: 'The Archer', }, replier: { - id: 'annual_report.summary.archetype.replier', - defaultMessage: 'The social butterfly', + id: 'annual_report.summary.archetype.replier.name', + defaultMessage: 'The Butterfly', }, pollster: { - id: 'annual_report.summary.archetype.pollster', - defaultMessage: 'The pollster', + id: 'annual_report.summary.archetype.pollster.name', + defaultMessage: 'The Wonderer', }, lurker: { - id: 'annual_report.summary.archetype.lurker', - defaultMessage: 'The lurker', + id: 'annual_report.summary.archetype.lurker.name', + defaultMessage: 'The Stoic', }, oracle: { - id: 'annual_report.summary.archetype.oracle', - defaultMessage: 'The oracle', + id: 'annual_report.summary.archetype.oracle.name', + defaultMessage: 'The Oracle', }, }); -export const Archetype: React.FC<{ - data: ArchetypeData; -}> = ({ data }) => { - const intl = useIntl(); - let illustration; +export const archetypeSelfDescriptions = defineMessages({ + booster: { + id: 'annual_report.summary.archetype.booster.desc_self', + defaultMessage: + 'You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.', + }, + replier: { + id: 'annual_report.summary.archetype.replier.desc_self', + defaultMessage: + 'You frequently replied to other people’s posts, pollinating Mastodon with new discussions.', + }, + pollster: { + id: 'annual_report.summary.archetype.pollster.desc_self', + defaultMessage: + 'You created more polls than other post types, cultivating curiosity on Mastodon.', + }, + lurker: { + id: 'annual_report.summary.archetype.lurker.desc_self', + defaultMessage: + 'We know you were out there, somewhere, enjoying Mastodon in your own quiet way.', + }, + oracle: { + id: 'annual_report.summary.archetype.oracle.desc_self', + defaultMessage: + 'You created new posts more than replies, keeping Mastodon fresh and future-facing.', + }, +}); - switch (data) { - case 'booster': - illustration = booster; - break; - case 'replier': - illustration = replier; - break; - case 'pollster': - illustration = pollster; - break; - case 'lurker': - illustration = lurker; - break; - case 'oracle': - illustration = oracle; - break; - } +export const archetypePublicDescriptions = defineMessages({ + booster: { + id: 'annual_report.summary.archetype.booster.desc_public', + defaultMessage: + '{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.', + }, + replier: { + id: 'annual_report.summary.archetype.replier.desc_public', + defaultMessage: + '{name} frequently replied to other people’s posts, pollinating Mastodon with new discussions.', + }, + pollster: { + id: 'annual_report.summary.archetype.pollster.desc_public', + defaultMessage: + '{name} created more polls than other post types, cultivating curiosity on Mastodon.', + }, + lurker: { + id: 'annual_report.summary.archetype.lurker.desc_public', + defaultMessage: + 'We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.', + }, + oracle: { + id: 'annual_report.summary.archetype.oracle.desc_public', + defaultMessage: + '{name} created new posts more than replies, keeping Mastodon fresh and future-facing.', + }, +}); + +const illustrations = { + booster, + replier, + pollster, + lurker, + oracle, +} as const; + +export const Archetype: React.FC<{ + report: AnnualReport; + account?: Account; + canShare: boolean; +}> = ({ report, account, canShare }) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const isSelfView = account?.id === me; + + const [isRevealed, setIsRevealed] = useState(!isSelfView); + const reveal = useCallback(() => { + setIsRevealed(true); + wrapperRef.current?.focus(); + }, []); + + const archetype = report.data.archetype; + const descriptions = isSelfView + ? archetypeSelfDescriptions + : archetypePublicDescriptions; + + const name = account?.display_name; return ( -
-
- {intl.formatMessage(archetypeNames[data])} +
+
+ {account && ( + + )} +
+ +
+
- +
+

+ {isSelfView ? ( + + ) : ( + + )} +

+

+ {isRevealed ? ( + intl.formatMessage(archetypeNames[archetype]) + ) : ( + + )} +

+

+ {isRevealed ? ( + intl.formatMessage(descriptions[archetype], { + name, + }) + ) : ( + + )} +

+
+ {!isRevealed && ( + + )} + {isRevealed && canShare && }
); }; diff --git a/app/javascript/mastodon/features/annual_report/followers.tsx b/app/javascript/mastodon/features/annual_report/followers.tsx index 196013ae9d..b0f2216bc5 100644 --- a/app/javascript/mastodon/features/annual_report/followers.tsx +++ b/app/javascript/mastodon/features/annual_report/followers.tsx @@ -1,68 +1,24 @@ import { FormattedMessage, FormattedNumber } from 'react-intl'; -import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import classNames from 'classnames'; -import { ShortNumber } from 'mastodon/components/short_number'; -import type { TimeSeriesMonth } from 'mastodon/models/annual_report'; +import styles from './index.module.scss'; export const Followers: React.FC<{ - data: TimeSeriesMonth[]; - total?: number; -}> = ({ data, total }) => { - const change = data.reduce((sum, item) => sum + item.followers, 0); - - const cumulativeGraph = data.reduce( - (newData, item) => [ - ...newData, - item.followers + (newData[newData.length - 1] ?? 0), - ], - [0], - ); - + count: number; +}> = ({ count }) => { return ( -
- - - - - - - - - +
+
+ +
- - - -
-
- {change > -1 ? '+' : '-'} - -
- -
- - - -
- }} - /> -
-
+
+
); diff --git a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx index 7edbb2e614..5ce4947609 100644 --- a/app/javascript/mastodon/features/annual_report/highlighted_post.tsx +++ b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx @@ -1,102 +1,77 @@ /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, - @typescript-eslint/no-unsafe-assignment */ - -import { useCallback } from 'react'; + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call */ import { FormattedMessage } from 'react-intl'; -import { DisplayName } from '@/mastodon/components/display_name'; -import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; -import { DetailedStatus } from 'mastodon/features/status/components/detailed_status'; -import { me } from 'mastodon/initial_state'; +import classNames from 'classnames'; + +import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import type { TopStatuses } from 'mastodon/models/annual_report'; -import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { makeGetStatus } from 'mastodon/selectors'; +import { useAppSelector } from 'mastodon/store'; + +import styles from './index.module.scss'; const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any; -const getPictureInPicture = makeGetPictureInPicture() as unknown as ( - arg0: any, - arg1: any, -) => any; export const HighlightedPost: React.FC<{ data: TopStatuses; }> = ({ data }) => { - let statusId, label; + const { by_reblogs, by_favourites, by_replies } = data; - if (data.by_reblogs) { - statusId = data.by_reblogs; - label = ( - - ); - } else if (data.by_favourites) { - statusId = data.by_favourites; - label = ( - - ); - } else { - statusId = data.by_replies; - label = ( - - ); - } + const statusId = by_reblogs || by_favourites || by_replies; - const dispatch = useAppDispatch(); - const domain = useAppSelector((state) => state.meta.get('domain')); const status = useAppSelector((state) => statusId ? getStatus(state, { id: statusId }) : undefined, ); - const pictureInPicture = useAppSelector((state) => - statusId ? getPictureInPicture(state, { id: statusId }) : undefined, - ); - const account = useAppSelector((state) => - me ? state.accounts.get(me) : undefined, - ); - - const handleToggleHidden = useCallback(() => { - dispatch(toggleStatusSpoilers(statusId)); - }, [dispatch, statusId]); if (!status) { - return ( -
+ return
; + } + + let label; + if (by_reblogs) { + label = ( + + ); + } else if (by_favourites) { + label = ( + + ); + } else { + label = ( + ); } - const displayName = ( - - - , - }} - /> - - {label} - - ); - return ( -
- +
+
+

+ +

+

{label}

+
+ +
); }; diff --git a/app/javascript/mastodon/features/annual_report/index.module.scss b/app/javascript/mastodon/features/annual_report/index.module.scss new file mode 100644 index 0000000000..0258f9c798 --- /dev/null +++ b/app/javascript/mastodon/features/annual_report/index.module.scss @@ -0,0 +1,283 @@ +.modalWrapper { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + padding: 40px; + overflow-y: auto; + pointer-events: none; + scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary); + + .loading-indicator .circular-progress { + color: var(--lime); + } +} + +.closeButton { + position: absolute; + top: 24px; + right: 24px; + padding: 8px; + border-radius: 100%; + + --default-icon-color: var(--color-bg-primary); + --default-bg-color: var(--color-text-primary); + --hover-icon-color: var(--color-bg-primary); + --hover-bg-color: var(--color-text-primary); +} + +.wrapper { + position: relative; + max-width: 600px; + padding: 24px; + contain: layout; + flex: 0 0 auto; + pointer-events: auto; + color: var(--color-text-primary); + background: var(--color-bg-primary); + background: + radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%), + radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%), + radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%), + radial-gradient(at 16% 95%, #1e948299 0, transparent 50%), + radial-gradient(at 80% 91%, #16dae499 0, transparent 50%) + var(--color-bg-primary); + border-radius: 40px; + + @media (width < 600px) { + padding: 12px; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: inherit; + border-radius: inherit; + filter: blur(20px); + } +} + +.header { + margin-bottom: 18px; + text-align: center; + + h1 { + font-family: monospace; + text-transform: uppercase; + letter-spacing: 0.15em; + font-size: 30px; + font-weight: 600; + line-height: 1.5; + margin-bottom: 8px; + } + + p { + font-size: 14px; + line-height: 1.5; + } +} + +.stack { + --grid-spacing: 12px; + + display: grid; + gap: var(--grid-spacing); +} + +.box { + position: relative; + padding: 16px; + border-radius: 16px; + background: rgb(from var(--color-bg-primary) r g b / 60%); + box-shadow: inset 0 0 0 1px rgb(from var(--color-text-primary) r g b / 40%); + + &::before, + &::after { + content: ''; + position: absolute; + inset-inline: 0; + display: block; + height: 1px; + background-image: linear-gradient( + to right, + transparent, + white, + transparent + ); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } +} + +.content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: 16px; + font-size: 14px; + text-align: center; + text-wrap: balance; + + &.comfortable { + gap: 12px; + } +} + +.title { + text-transform: uppercase; + color: #c2c8ff; + font-weight: 500; +} + +.statLarge { + font-size: 24px; + font-weight: 500; + overflow-wrap: break-word; +} + +.statExtraLarge { + font-size: 32px; + font-weight: 500; + line-height: 1; + overflow-wrap: break-word; +} + +.mostBoostedPost { + padding: 0; + padding-top: 8px; + overflow: hidden; +} + +.statsGrid { + display: grid; + gap: var(--grid-spacing); + grid-template-columns: 1fr 2fr; + grid-template-areas: + 'followers hashtag' + 'new-posts hashtag'; + + @media (width < 680px) { + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'followers new-posts' + 'hashtag hashtag'; + } + + &:empty { + display: none; + } + + &.onlyHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'hashtag'; + } + + &.noHashtag { + grid-template-columns: 1fr 1fr; + grid-template-areas: 'followers new-posts'; + } + + &.singleNumber { + grid-template-columns: 1fr 2fr; + grid-template-areas: 'number hashtag'; + + @media (width < 680px) { + grid-template-areas: + 'number number' + 'hashtag hashtag'; + } + } + + &.singleNumber.noHashtag { + grid-template-columns: 1fr; + grid-template-areas: 'number'; + } +} + +.followers { + grid-area: followers; + + .singleNumber & { + grid-area: number; + } +} + +.newPosts { + grid-area: new-posts; + + .singleNumber & { + grid-area: number; + } +} + +.mostUsedHashtag { + grid-area: hashtag; + padding-block: 24px; +} + +.archetype { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.archetypeArtboard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + width: 180px; + padding-top: 40px; +} + +.archetypeAvatar { + position: absolute; + top: 7px; + left: 4px; + border-radius: 100%; + overflow: hidden; +} + +.archetypeIllustrationWrapper { + position: relative; + width: 92px; + aspect-ratio: 1; + overflow: hidden; + border-radius: 100%; + + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset -10px -4px 15px #00000080; + } +} + +.archetypeIllustration { + width: 100%; +} + +.blurredImage { + filter: blur(10px); +} + +.archetypePlanetRing { + position: absolute; + top: 0; + left: 0; + mix-blend-mode: screen; +} diff --git a/app/javascript/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx index e9f0b5f2d7..b02e8fb898 100644 --- a/app/javascript/mastodon/features/annual_report/index.tsx +++ b/app/javascript/mastodon/features/annual_report/index.tsx @@ -1,95 +1,123 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { FC } from 'react'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; -import { focusCompose, resetCompose } from '@/mastodon/actions/compose'; +import { useLocation } from 'react-router'; + +import classNames from 'classnames/bind'; + import { closeModal } from '@/mastodon/actions/modal'; -import { Button } from '@/mastodon/components/button'; +import { IconButton } from '@/mastodon/components/icon_button'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { me } from '@/mastodon/initial_state'; -import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Archetype, archetypeNames } from './archetype'; +import { Archetype } from './archetype'; import { Followers } from './followers'; import { HighlightedPost } from './highlighted_post'; +import styles from './index.module.scss'; import { MostUsedHashtag } from './most_used_hashtag'; import { NewPosts } from './new_posts'; -const shareMessage = defineMessage({ +const moduleClassNames = classNames.bind(styles); + +export const shareMessage = defineMessage({ id: 'annual_report.summary.share_message', defaultMessage: 'I got the {archetype} archetype!', }); // Share = false when using the embedded version of the report. -export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => { - const currentAccount = useAppSelector((state) => - me ? state.accounts.get(me) : 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 close = useCallback(() => { + dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); + }, [dispatch]); + + // Close modal when navigating away from within + const { pathname } = useLocation(); + const [initialPathname] = useState(pathname); + useEffect(() => { + if (pathname !== initialPathname) { + close(); + } + }, [pathname, initialPathname, close]); if (!report) { return ; } + const newPostCount = report.data.time_series.reduce( + (sum, item) => sum + item.statuses, + 0, + ); + + const newFollowerCount = report.data.time_series.reduce( + (sum, item) => sum + item.followers, + 0, + ); + + const topHashtag = report.data.top_hashtags[0]; + return ( -
-
+
+

-

-

- -

+

+ {account &&

@{account.acct}

} + {context === 'modal' && ( + + )}
-
- +
- + {!!newFollowerCount && } + {!!newPostCount && } + {topHashtag && } +
+ - - - {share && }
); }; - -const ShareButton: FC<{ report: AnnualReportData }> = ({ report }) => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const handleShareClick = useCallback(() => { - // Generate the share message. - const archetypeName = intl.formatMessage( - archetypeNames[report.data.archetype], - ); - const shareLines = [ - intl.formatMessage(shareMessage, { - archetype: archetypeName, - }), - ]; - // Share URL is only available for schema version 2. - if (report.schema_version === 2 && report.share_url) { - shareLines.push(report.share_url); - } - shareLines.push(`#Wrapstodon${report.year}`); - - // Reset the composer and focus it with the share message, then close the modal. - dispatch(resetCompose()); - dispatch(focusCompose(shareLines.join('\n\n'))); - dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false })); - }, [report, intl, dispatch]); - - return