Update Wrapstodon design (#37169)

This commit is contained in:
diondiondion
2025-12-09 17:51:05 +01:00
committed by GitHub
parent ac71771d98
commit 9d81561bb2
24 changed files with 773 additions and 677 deletions

View File

@@ -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')

View File

@@ -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() {
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonShare />
<WrapstodonSharedPage />
</Router>
</ReduxProvider>
</IntlProvider>,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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) &&
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
}
</div>

View File

@@ -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 (
<div className={styles.wrapper}>
<div className={classNames('theme-dark', styles.wrapper)}>
<h2>
<FormattedMessage
id='annual_report.announcement.title'

View File

@@ -6,14 +6,14 @@
text-align: center;
font-size: 15px;
line-height: 1.5;
color: var(--color-text-on-media);
background: var(--color-bg-media-base);
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%)
var(--color-bg-media-base);
var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary);
h2 {

View File

@@ -1,65 +1,215 @@
import { defineMessages, useIntl } from 'react-intl';
import { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png';
import pollster from '@/images/archetypes/pollster.png';
import replier from '@/images/archetypes/replier.png';
import type { Archetype as ArchetypeData } from '@/mastodon/models/annual_report';
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 styles from './index.module.scss';
import { ShareButton } from './share_button';
export const archetypeNames = defineMessages<ArchetypeData>({
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<ArchetypeData>({
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 peoples 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<ArchetypeData>({
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 peoples 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<HTMLDivElement>(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 (
<div className='annual-report__bento__box annual-report__summary__archetype'>
<div className='annual-report__summary__archetype__label'>
{intl.formatMessage(archetypeNames[data])}
<div
className={classNames(styles.box, styles.archetype)}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={wrapperRef}
>
<div className={styles.archetypeArtboard}>
{account && (
<Avatar
account={account}
size={50}
className={styles.archetypeAvatar}
/>
)}
<div className={styles.archetypeIllustrationWrapper}>
<img
src={illustrations[archetype]}
alt=''
className={classNames(
styles.archetypeIllustration,
isRevealed ? '' : styles.blurredImage,
)}
/>
</div>
<img
src={space_elements}
alt=''
className={styles.archetypePlanetRing}
/>
</div>
<img src={illustration} alt='' />
<div className={classNames(styles.content, styles.comfortable)}>
<h2 className={styles.title}>
{isSelfView ? (
<FormattedMessage
id='annual_report.summary.archetype.title_self'
defaultMessage='Your archetype'
/>
) : (
<FormattedMessage
id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype"
values={{ name }}
/>
)}
</h2>
<p className={styles.statLarge}>
{isRevealed ? (
intl.formatMessage(archetypeNames[archetype])
) : (
<FormattedMessage
id='annual_report.summary.archetype.die_drei_fragezeichen'
defaultMessage='???'
/>
)}
</p>
<p>
{isRevealed ? (
intl.formatMessage(descriptions[archetype], {
name,
})
) : (
<FormattedMessage
id='annual_report.summary.archetype.reveal_description'
defaultMessage='Thanks for being part of Mastodon! Time to find out which archetype you embodied in {year}.'
values={{ year: report.year }}
/>
)}
</p>
</div>
{!isRevealed && (
<Button onClick={reveal}>
<FormattedMessage
id='annual_report.summary.archetype.reveal'
defaultMessage='Reveal my archetype'
/>
</Button>
)}
{isRevealed && canShare && <ShareButton report={report} />}
</div>
);
};

View File

@@ -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 (
<div className='annual-report__bento__box annual-report__summary__followers'>
<Sparklines data={cumulativeGraph} margin={0}>
<svg>
<defs>
<linearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop
offset='0%'
stopColor='var(--sparkline-gradient-top)'
stopOpacity='1'
/>
<stop
offset='100%'
stopColor='var(--sparkline-gradient-bottom)'
stopOpacity='0'
/>
</linearGradient>
</defs>
</svg>
<div className={classNames(styles.box, styles.followers, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
<div className='annual-report__summary__followers__foreground'>
<div className='annual-report__summary__followers__number'>
{change > -1 ? '+' : '-'}
<FormattedNumber value={change} />
</div>
<div className='annual-report__summary__followers__label'>
<span>
<FormattedMessage
id='annual_report.summary.followers.followers'
defaultMessage='followers'
/>
</span>
<div className='annual-report__summary__followers__footnote'>
<FormattedMessage
id='annual_report.summary.followers.total'
defaultMessage='{count} total'
values={{ count: <ShortNumber value={total ?? 0} /> }}
/>
</div>
</div>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.followers.new_followers'
defaultMessage='{count, plural, one {new follower} other {new followers}}'
values={{ count }}
/>
</div>
</div>
);

View File

@@ -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 = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_reblogs'
defaultMessage='most boosted post'
/>
);
} else if (data.by_favourites) {
statusId = data.by_favourites;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_favourites'
defaultMessage='most favourited post'
/>
);
} else {
statusId = data.by_replies;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_replies'
defaultMessage='post with the most replies'
/>
);
}
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 (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
}
let label;
if (by_reblogs) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.boost_count'
defaultMessage='This post was boosted {count, plural, one {once} other {# times}}.'
values={{ count: status.get('reblogs_count') }}
/>
);
} else if (by_favourites) {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.favourite_count'
defaultMessage='This post was favorited {count, plural, one {once} other {# times}}.'
values={{ count: status.get('favourites_count') }}
/>
);
} else {
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.reply_count'
defaultMessage='This post got {count, plural, one {one reply} other {# replies}}.'
values={{ count: status.get('replies_count') }}
/>
);
}
const displayName = (
<span className='display-name'>
<strong className='display-name__html'>
<FormattedMessage
id='annual_report.summary.highlighted_post.possessive'
defaultMessage="{name}'s"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</strong>
<span className='display-name__account'>{label}</span>
</span>
);
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
<DetailedStatus
status={status}
pictureInPicture={pictureInPicture}
domain={domain}
onToggleHidden={handleToggleHidden}
overrideDisplayName={displayName}
/>
<div className={classNames(styles.box, styles.mostBoostedPost)}>
<div className={styles.content}>
<h2 className={styles.title}>
<FormattedMessage
id='annual_report.summary.highlighted_post.title'
defaultMessage='Most popular post'
/>
</h2>
<p>{label}</p>
</div>
<StatusQuoteManager showActions={false} id={`${statusId}`} />
</div>
);
};

View File

@@ -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;
}

View File

@@ -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 <LoadingIndicator />;
}
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 (
<div className='annual-report'>
<div className='annual-report__header'>
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}>
<h1>
<FormattedMessage
id='annual_report.summary.thanks'
defaultMessage='Thanks for being part of Mastodon!'
/>
</h1>
<p>
<FormattedMessage
id='annual_report.summary.here_it_is'
defaultMessage='Here is your {year} in review:'
id='annual_report.summary.title'
defaultMessage='Wrapstodon {year}'
values={{ year: report.year }}
/>
</p>
</h1>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton
title={intl.formatMessage({
id: 'annual_report.summary.close',
defaultMessage: 'Close',
})}
className={styles.closeButton}
icon='close'
iconComponent={CloseIcon}
onClick={close}
/>
)}
</div>
<div className='annual-report__bento annual-report__summary'>
<Archetype data={report.data.archetype} />
<div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} />
<Followers
data={report.data.time_series}
total={currentAccount?.followers_count}
<div
className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag,
onlyHashtag: !(newFollowerCount && newPostCount),
singleNumber: !!newFollowerCount !== !!newPostCount,
})}
>
{!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && <MostUsedHashtag hashtag={topHashtag} />}
</div>
<Archetype
report={report}
account={account}
canShare={context === 'modal'}
/>
<MostUsedHashtag data={report.data.top_hashtags} />
<NewPosts data={report.data.time_series} />
{share && <ShareButton report={report} />}
</div>
</div>
);
};
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 <Button text='Share here' onClick={handleShareClick} />;
};

View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import classNames from 'classnames';
import { AnnualReport } from '.';
import styles from './index.module.scss';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]);
return (
<div
className={classNames(
'modal-root__modal',
styles.modalWrapper,
'theme-dark',
)}
>
<AnnualReport context='modal' />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -1,30 +1,34 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { NameAndCount } from 'mastodon/models/annual_report';
export const MostUsedHashtag: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const hashtag = data[0];
import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount;
}> = ({ hashtag }) => {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
<div className='annual-report__summary__most-used-hashtag__hashtag'>
{hashtag ? (
<>#{hashtag.name}</>
) : (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.none'
defaultMessage='None'
/>
)}
</div>
<div className='annual-report__summary__most-used-hashtag__label'>
<div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
defaultMessage='most used hashtag'
defaultMessage='Most used hashtag'
/>
</div>
<div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
</p>
</div>
);
};

View File

@@ -1,51 +1,23 @@
import { FormattedNumber, FormattedMessage } from 'react-intl';
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
import type { TimeSeriesMonth } from 'mastodon/models/annual_report';
import classNames from 'classnames';
import styles from './index.module.scss';
export const NewPosts: React.FC<{
data: TimeSeriesMonth[];
}> = ({ data }) => {
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
count: number;
}> = ({ count }) => {
return (
<div className='annual-report__bento__box annual-report__summary__new-posts'>
<svg width={500} height={500}>
<defs>
<pattern
id='posts'
x='0'
y='0'
width='32'
height='35'
patternUnits='userSpaceOnUse'
>
<circle cx='12' cy='12' r='12' fill='var(--lime)' />
<ChatBubbleIcon
fill='var(--indigo-1)'
x='4'
y='4'
width='16'
height='16'
/>
</pattern>
</defs>
<rect
width={500}
height={500}
fill='url(#posts)'
style={{ opacity: 0.2 }}
/>
</svg>
<div className='annual-report__summary__new-posts__number'>
<FormattedNumber value={posts} />
<div className={classNames(styles.box, styles.newPosts, styles.content)}>
<div className={styles.statLarge}>
<FormattedNumber value={count} />
</div>
<div className='annual-report__summary__new-posts__label'>
<div className={styles.title}>
<FormattedMessage
id='annual_report.summary.new_posts.new_posts'
defaultMessage='new posts'
defaultMessage='{count, plural, one {new post} other {new posts}}'
values={{ count }}
/>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import { resetCompose, focusCompose } from '@/mastodon/actions/compose';
import { closeModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
import { useAppDispatch } from '@/mastodon/store';
import { shareMessage } from '.';
import { archetypeNames } from './archetype';
export 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 <Button text='Share here' onClick={handleShareClick} />;
};

View File

@@ -1,6 +1,6 @@
.wrapper {
max-width: 40rem;
margin: 0 auto;
max-width: max-content;
margin: 40px auto;
}
.footer {

View File

@@ -3,12 +3,12 @@ import type { FC } from 'react';
import { IconLogo } from '@/mastodon/components/logo';
import { AnnualReport } from './index';
import classes from './share.module.css';
import classes from './shared_page.module.css';
export const WrapstodonShare: FC = () => {
export const WrapstodonSharedPage: FC = () => {
return (
<main className={classes.wrapper}>
<AnnualReport share={false} />
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
Generated with by the Mastodon team

View File

@@ -1,20 +0,0 @@
import { useEffect } from 'react';
import { AnnualReport } from 'mastodon/features/annual_report';
const AnnualReportModal: React.FC<{
onChangeBackgroundColor: (color: string) => void;
}> = ({ onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--indigo-1)');
}, [onChangeBackgroundColor]);
return (
<div className='modal-root__modal annual-report-modal'>
<AnnualReport />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@@ -227,7 +227,7 @@ export function LinkTimeline () {
}
export function AnnualReportModal () {
return import('../components/annual_report_modal');
return import('../../annual_report/modal');
}
export function ListEdit () {

View File

@@ -117,26 +117,40 @@
"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.title": "Wrapstodon {year} has arrived",
"annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "The lurker",
"annual_report.summary.archetype.oracle": "The oracle",
"annual_report.summary.archetype.pollster": "The pollster",
"annual_report.summary.archetype.replier": "The social butterfly",
"annual_report.summary.followers.followers": "followers",
"annual_report.summary.followers.total": "{count} total",
"annual_report.summary.here_it_is": "Here is your {year} in review:",
"annual_report.summary.highlighted_post.by_favourites": "most favourited post",
"annual_report.summary.highlighted_post.by_reblogs": "most boosted post",
"annual_report.summary.highlighted_post.by_replies": "post with the most replies",
"annual_report.summary.highlighted_post.possessive": "{name}'s",
"annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.name": "The Archer",
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
"annual_report.summary.archetype.lurker.desc_public": "We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.",
"annual_report.summary.archetype.lurker.desc_self": "We know you were out there, somewhere, enjoying Mastodon in your own quiet way.",
"annual_report.summary.archetype.lurker.name": "The Stoic",
"annual_report.summary.archetype.oracle.desc_public": "{name} created new posts more than replies, keeping Mastodon fresh and future-facing.",
"annual_report.summary.archetype.oracle.desc_self": "You created new posts more than replies, keeping Mastodon fresh and future-facing.",
"annual_report.summary.archetype.oracle.name": "The Oracle",
"annual_report.summary.archetype.pollster.desc_public": "{name} created more polls than other post types, cultivating curiosity on Mastodon.",
"annual_report.summary.archetype.pollster.desc_self": "You created more polls than other post types, cultivating curiosity on Mastodon.",
"annual_report.summary.archetype.pollster.name": "The Wonderer",
"annual_report.summary.archetype.replier.desc_public": "{name} frequently replied to other peoples posts, pollinating Mastodon with new discussions.",
"annual_report.summary.archetype.replier.desc_self": "You frequently replied to other peoples posts, pollinating Mastodon with new discussions.",
"annual_report.summary.archetype.replier.name": "The Butterfly",
"annual_report.summary.archetype.reveal": "Reveal my archetype",
"annual_report.summary.archetype.reveal_description": "Thanks for being part of Mastodon! Time to find out which archetype you embodied in {year}.",
"annual_report.summary.archetype.title_public": "{name}'s archetype",
"annual_report.summary.archetype.title_self": "Your archetype",
"annual_report.summary.close": "Close",
"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.favourite_count": "This post was favorited {count, plural, one {once} other {# times}}.",
"annual_report.summary.highlighted_post.reply_count": "This post got {count, plural, one {one reply} other {# replies}}.",
"annual_report.summary.highlighted_post.title": "Most popular post",
"annual_report.summary.most_used_app.most_used_app": "most used app",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag",
"annual_report.summary.most_used_hashtag.none": "None",
"annual_report.summary.most_used_hashtag.used_count": "You included this hashtag in {count, plural, one {one post} other {# 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.we_wont_tell_bernie": "We won't tell Bernie.",
"annual_report.summary.share_message": "I got the {archetype} archetype!",
"annual_report.summary.thanks": "Thanks for being part of Mastodon!",
"annual_report.summary.title": "Wrapstodon {year}",
"attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio",
"block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.",

View File

@@ -55,5 +55,6 @@ export type AnnualReport = {
schema_version: 2;
data: AnnualReportV2;
share_url: string | null;
account_id: string;
}
);

View File

@@ -16,7 +16,6 @@
@use 'mastodon/polls';
@use 'mastodon/modal';
@use 'mastodon/emoji_picker';
@use 'mastodon/annual_reports';
@use 'mastodon/about';
@use 'mastodon/tables';
@use 'mastodon/admin';

View File

@@ -1,342 +0,0 @@
@use 'variables' as *;
:root {
--indigo-1: #17063b;
--indigo-2: #2f0c7a;
--indigo-3: #562cfc;
--indigo-5: #858afa;
--indigo-6: #cccfff;
--lime: #baff3b;
--goldenrod-2: #ffc954;
}
.annual-report {
flex: 0 0 auto;
background: var(--indigo-1);
padding: 24px;
&__header {
margin-bottom: 16px;
h1 {
font-size: 25px;
font-weight: 600;
line-height: 30px;
color: var(--lime);
margin-bottom: 8px;
}
p {
font-size: 16px;
font-weight: 600;
line-height: 20px;
color: var(--indigo-6);
}
}
&__bento {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: minmax(0, auto) minmax(0, 1fr) minmax(0, auto) minmax(
0,
auto
);
&__box {
padding: 16px;
border-radius: 8px;
background: var(--indigo-2);
color: var(--indigo-5);
}
}
&__summary {
&__most-boosted-post {
grid-column: span 2;
grid-row: span 2;
padding: 0;
.status__content,
.content-warning {
color: var(--indigo-6);
}
.detailed-status {
border: 0;
}
.content-warning {
border: 0;
background: var(--indigo-1);
.link-button {
color: var(--indigo-5);
}
}
.detailed-status__meta__line {
border-bottom-color: var(--indigo-3);
}
.detailed-status__meta {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.detailed-status__meta,
.poll__footer,
.poll__link,
.detailed-status .logo,
.detailed-status__display-name {
color: var(--indigo-5);
}
.detailed-status__meta .animated-number,
.detailed-status__display-name strong {
color: var(--indigo-6);
}
.poll__chart {
background-color: var(--indigo-3);
&.leading {
background-color: var(--goldenrod-2);
}
}
.status-card,
.hashtag-bar {
display: none;
}
}
&__followers {
grid-column: span 1;
text-align: center;
position: relative;
overflow: hidden;
padding-block-start: 24px;
padding-block-end: 24px;
--sparkline-gradient-top: rgba(86, 44, 252, 50%);
--sparkline-gradient-bottom: rgba(86, 44, 252, 0%);
&__foreground {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
z-index: 1;
}
&__number {
font-size: 31px;
font-weight: 600;
line-height: 37px;
color: var(--lime);
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
color: var(--indigo-6);
}
&__footnote {
display: block;
font-weight: 400;
opacity: 0.5;
}
svg {
position: absolute;
bottom: 0;
inset-inline-end: 0;
pointer-events: none;
z-index: 0;
height: 70%;
width: auto;
path:first-child {
fill: url('#gradient') !important;
fill-opacity: 1 !important;
}
path:last-child {
stroke: var(--color-graph-primary-stroke) !important;
fill: none !important;
}
}
}
&__archetype {
grid-column: span 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
padding: 0;
img {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
&__label {
padding: 16px;
padding-bottom: 8px;
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--lime);
}
}
&__most-used-app {
grid-column: span 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
&__label {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--indigo-6);
}
&__icon {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--goldenrod-2);
}
}
&__percentile {
grid-row: span 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
text-align: center;
text-wrap: balance;
padding: 16px 8px;
&__label {
font-size: 14px;
line-height: 17px;
}
&__number {
font-size: 54px;
font-weight: 600;
line-height: 73px;
color: var(--goldenrod-2);
}
&__footnote {
font-size: 11px;
line-height: 14px;
opacity: 0.5;
}
}
&__new-posts {
grid-column: span 2;
text-align: center;
position: relative;
overflow: hidden;
&__label {
font-size: 20px;
font-weight: 600;
line-height: 24px;
color: var(--indigo-6);
z-index: 1;
position: relative;
}
&__number {
font-size: 76px;
font-weight: 600;
line-height: 91px;
color: var(--goldenrod-2);
z-index: 1;
position: relative;
}
svg {
position: absolute;
inset-inline-start: -7px;
top: -4px;
z-index: 0;
}
}
&__most-used-hashtag {
grid-column: span 2;
text-align: center;
overflow: hidden;
&__hashtag {
font-size: 42px;
font-weight: 600;
line-height: 58px;
color: var(--indigo-6);
margin-inline-start: -100%;
margin-inline-end: -100%;
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
}
}
}
}
.annual-report-modal {
max-width: 600px;
background: var(--indigo-1);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
.loading-indicator .circular-progress {
color: var(--lime);
}
@media screen and (max-width: $no-columns-breakpoint) {
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
}
.notification-group--annual-report {
.notification-group__icon {
color: var(--lime);
}
.notification-group__main .link-button {
font-weight: 500;
color: var(--lime);
}
}

View File

@@ -8,5 +8,7 @@
= vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
- content_for :html_classes, 'theme-dark'
#wrapstodon
= render_wrapstodon_share_data @generated_annual_report