mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-12 23:38:20 +00:00
Merge pull request #3311 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 9d81561bb2
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -99,7 +99,7 @@ function lodash(_ref) {
|
||||
|
||||
var node = _ref3;
|
||||
|
||||
- if ((0, _types.isModuleDeclaration)(node)) {
|
||||
+ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) {
|
||||
isModule = true;
|
||||
break;
|
||||
}
|
||||
@@ -304,8 +304,8 @@ GEM
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.26.1)
|
||||
redis-client (= 0.26.1)
|
||||
hiredis-client (0.26.2)
|
||||
redis-client (= 0.26.2)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
@@ -469,7 +469,7 @@ GEM
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.12)
|
||||
oj (3.16.13)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -703,7 +703,7 @@ GEM
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.26.1)
|
||||
redis-client (0.26.2)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
|
||||
@@ -71,7 +71,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
def set_collections
|
||||
@collections = @account.collections
|
||||
.with_tag
|
||||
.with_item_count
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,13 +2,11 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
|
||||
import {
|
||||
importFetchedAccounts,
|
||||
importFetchedStatuses,
|
||||
} from '@/mastodon/actions/importer';
|
||||
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';
|
||||
@@ -33,7 +31,14 @@ function loaded() {
|
||||
if (!report) {
|
||||
throw new Error('Initial state report not found');
|
||||
}
|
||||
store.dispatch(importFetchedAccounts(initialState.accounts));
|
||||
|
||||
// Set up store
|
||||
store.dispatch(
|
||||
hydrateStore({
|
||||
meta: { locale: document.documentElement.lang },
|
||||
accounts: initialState.accounts,
|
||||
}),
|
||||
);
|
||||
store.dispatch(importFetchedStatuses(initialState.statuses));
|
||||
|
||||
store.dispatch(setReport(report));
|
||||
@@ -43,7 +48,7 @@ function loaded() {
|
||||
<IntlProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<Router>
|
||||
<WrapstodonShare />
|
||||
<WrapstodonSharedPage />
|
||||
</Router>
|
||||
</ReduxProvider>
|
||||
</IntlProvider>,
|
||||
|
||||
@@ -37,7 +37,9 @@ export function hydrateStore(rawState) {
|
||||
|
||||
dispatch(hydrateCompose());
|
||||
dispatch(hydrateSearch());
|
||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||
if (rawState.accounts) {
|
||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||
}
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ class Status extends ImmutablePureComponent {
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
showActions: PropTypes.bool,
|
||||
prepend: PropTypes.string,
|
||||
withDismiss: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
@@ -465,7 +466,8 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenMedia,
|
||||
notification,
|
||||
history,
|
||||
isQuotedPost,
|
||||
showActions = true,
|
||||
isQuotedPost = false,
|
||||
...other
|
||||
} = this.props;
|
||||
let attachments = null;
|
||||
@@ -763,7 +765,7 @@ class Status extends ImmutablePureComponent {
|
||||
{/* This is a glitch-soc addition to have a placeholder */}
|
||||
{!expanded && <MentionsPlaceholder status={status} />}
|
||||
|
||||
{!isQuotedPost &&
|
||||
{(showActions && !isQuotedPost) &&
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
account={status.get('account')}
|
||||
|
||||
@@ -2,13 +2,11 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
|
||||
import {
|
||||
importFetchedAccounts,
|
||||
importFetchedStatuses,
|
||||
} from '@/flavours/glitch/actions/importer';
|
||||
import { importFetchedStatuses } from '@/flavours/glitch/actions/importer';
|
||||
import { hydrateStore } from '@/flavours/glitch/actions/store';
|
||||
import type { ApiAnnualReportResponse } from '@/flavours/glitch/api/annual_report';
|
||||
import { Router } from '@/flavours/glitch/components/router';
|
||||
import { WrapstodonShare } from '@/flavours/glitch/features/annual_report/share';
|
||||
import { WrapstodonSharedPage } from '@/flavours/glitch/features/annual_report/shared_page';
|
||||
import { IntlProvider, loadLocale } from '@/flavours/glitch/locales';
|
||||
import { loadPolyfills } from '@/flavours/glitch/polyfills';
|
||||
import ready from '@/flavours/glitch/ready';
|
||||
@@ -33,7 +31,14 @@ function loaded() {
|
||||
if (!report) {
|
||||
throw new Error('Initial state report not found');
|
||||
}
|
||||
store.dispatch(importFetchedAccounts(initialState.accounts));
|
||||
|
||||
// Set up store
|
||||
store.dispatch(
|
||||
hydrateStore({
|
||||
meta: { locale: document.documentElement.lang },
|
||||
accounts: initialState.accounts,
|
||||
}),
|
||||
);
|
||||
store.dispatch(importFetchedStatuses(initialState.statuses));
|
||||
|
||||
store.dispatch(setReport(report));
|
||||
@@ -43,7 +48,7 @@ function loaded() {
|
||||
<IntlProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<Router>
|
||||
<WrapstodonShare />
|
||||
<WrapstodonSharedPage />
|
||||
</Router>
|
||||
</ReduxProvider>
|
||||
</IntlProvider>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Button } from '@/flavours/glitch/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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,65 +1,215 @@
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import type { Archetype as ArchetypeData } from '@/flavours/glitch/models/annual_report';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
import { me } from '@/flavours/glitch/initial_state';
|
||||
import type { Account } from '@/flavours/glitch/models/account';
|
||||
import type {
|
||||
AnnualReport,
|
||||
Archetype as ArchetypeData,
|
||||
} from '@/flavours/glitch/models/annual_report';
|
||||
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 space_elements from '@/images/archetypes/space_elements.png';
|
||||
|
||||
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 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<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 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<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import type { TimeSeriesMonth } from 'flavours/glitch/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>
|
||||
);
|
||||
|
||||
@@ -1,106 +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 '@/flavours/glitch/components/display_name';
|
||||
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
||||
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted';
|
||||
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
|
||||
import {
|
||||
makeGetStatus,
|
||||
makeGetPictureInPicture,
|
||||
} from 'flavours/glitch/selectors';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||
import { useAppSelector } from 'flavours/glitch/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}
|
||||
expanded={false}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '@/flavours/glitch/actions/compose';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import classNames from 'classnames/bind';
|
||||
|
||||
import { closeModal } from '@/flavours/glitch/actions/modal';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import { me } from '@/flavours/glitch/initial_state';
|
||||
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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} />;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -1,30 +1,34 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { NameAndCount } from 'flavours/glitch/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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 'flavours/glitch/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>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { resetCompose, focusCompose } from '@/flavours/glitch/actions/compose';
|
||||
import { closeModal } from '@/flavours/glitch/actions/modal';
|
||||
import { Button } from '@/flavours/glitch/components/button';
|
||||
import type { AnnualReport as AnnualReportData } from '@/flavours/glitch/models/annual_report';
|
||||
import { useAppDispatch } from '@/flavours/glitch/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} />;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
.wrapper {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
max-width: max-content;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -3,12 +3,12 @@ import type { FC } from 'react';
|
||||
import { IconLogo } from '@/flavours/glitch/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
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { AnnualReport } from 'flavours/glitch/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;
|
||||
@@ -231,7 +231,7 @@ export function LinkTimeline () {
|
||||
}
|
||||
|
||||
export function AnnualReportModal () {
|
||||
return import('../components/annual_report_modal');
|
||||
return import('../../annual_report/modal');
|
||||
}
|
||||
|
||||
export function ListEdit () {
|
||||
|
||||
@@ -55,5 +55,6 @@ export type AnnualReport = {
|
||||
schema_version: 2;
|
||||
data: AnnualReportV2;
|
||||
share_url: string | null;
|
||||
account_id: string;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type { ApiReportJSON } from 'flavours/glitch/api_types/reports';
|
||||
|
||||
// Maximum number of avatars displayed in a notification group
|
||||
// This corresponds to the max lenght of `group.sampleAccountIds`
|
||||
// This corresponds to the max length of `group.sampleAccountIds`
|
||||
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
|
||||
|
||||
interface BaseNotificationGroup
|
||||
|
||||
@@ -442,7 +442,9 @@ export const composeReducer = (state = initialState, action) => {
|
||||
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
if (action.state.get('compose'))
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
return state;
|
||||
case COMPOSE_MOUNT:
|
||||
return state
|
||||
.set('mounted', state.get('mounted') + 1)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
BIN
app/javascript/images/archetypes/space_elements.png
Normal file
BIN
app/javascript/images/archetypes/space_elements.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -22,6 +22,8 @@ export function hydrateStore(rawState) {
|
||||
|
||||
dispatch(hydrateCompose());
|
||||
dispatch(hydrateSearch());
|
||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||
if (rawState.accounts) {
|
||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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<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 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<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
283
app/javascript/mastodon/features/annual_report/index.module.scss
Normal file
283
app/javascript/mastodon/features/annual_report/index.module.scss
Normal 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;
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
29
app/javascript/mastodon/features/annual_report/modal.tsx
Normal file
29
app/javascript/mastodon/features/annual_report/modal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
.wrapper {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
max-width: max-content;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -227,7 +227,7 @@ export function LinkTimeline () {
|
||||
}
|
||||
|
||||
export function AnnualReportModal () {
|
||||
return import('../components/annual_report_modal');
|
||||
return import('../../annual_report/modal');
|
||||
}
|
||||
|
||||
export function ListEdit () {
|
||||
|
||||
@@ -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 people’s posts, pollinating Mastodon with new discussions.",
|
||||
"annual_report.summary.archetype.replier.desc_self": "You frequently replied to other people’s 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.",
|
||||
|
||||
@@ -55,5 +55,6 @@ export type AnnualReport = {
|
||||
schema_version: 2;
|
||||
data: AnnualReportV2;
|
||||
share_url: string | null;
|
||||
account_id: string;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type { ApiReportJSON } from 'mastodon/api_types/reports';
|
||||
|
||||
// Maximum number of avatars displayed in a notification group
|
||||
// This corresponds to the max lenght of `group.sampleAccountIds`
|
||||
// This corresponds to the max length of `group.sampleAccountIds`
|
||||
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
|
||||
|
||||
interface BaseNotificationGroup
|
||||
|
||||
@@ -363,7 +363,9 @@ export const composeReducer = (state = initialState, action) => {
|
||||
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
if (action.state.get('compose'))
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
return state;
|
||||
case COMPOSE_MOUNT:
|
||||
return state
|
||||
.set('mounted', state.get('mounted') + 1)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ module ApplicationExtension
|
||||
end
|
||||
|
||||
def redirect_uris
|
||||
# Doorkeeper stores the redirect_uri value as a newline delimeted list in
|
||||
# Doorkeeper stores the redirect_uri value as a newline delimited list in
|
||||
# the database:
|
||||
redirect_uri.split
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ class BulkImport < ApplicationRecord
|
||||
BulkImport.increment_counter(:processed_items, bulk_import_id)
|
||||
BulkImport.increment_counter(:imported_items, bulk_import_id) if imported
|
||||
|
||||
# Since the incrementation has been done atomically, concurrent access to `bulk_import` is now bening
|
||||
# Since the incrementation has been done atomically, concurrent access to `bulk_import` is now benign
|
||||
bulk_import = BulkImport.find(bulk_import_id)
|
||||
bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
|
||||
end
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# id :bigint(8) not null, primary key
|
||||
# description :text not null
|
||||
# discoverable :boolean not null
|
||||
# item_count :integer default(0), not null
|
||||
# local :boolean not null
|
||||
# name :string not null
|
||||
# original_number_of_items :integer
|
||||
@@ -39,11 +40,6 @@ class Collection < ApplicationRecord
|
||||
validate :items_do_not_exceed_limit
|
||||
|
||||
scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) }
|
||||
scope :with_item_count, lambda {
|
||||
select('collections.*, COUNT(collection_items.id)')
|
||||
.left_joins(:collection_items)
|
||||
.group(collections: :id)
|
||||
}
|
||||
scope :with_tag, -> { includes(:tag) }
|
||||
|
||||
def remote?
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# collection_id :bigint(8) not null
|
||||
#
|
||||
class CollectionItem < ApplicationRecord
|
||||
belongs_to :collection
|
||||
belongs_to :collection, counter_cache: :item_count
|
||||
belongs_to :account, optional: true
|
||||
|
||||
enum :state,
|
||||
|
||||
@@ -33,7 +33,7 @@ class GeneratedAnnualReport < ApplicationRecord
|
||||
when 1
|
||||
data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id')
|
||||
when 2
|
||||
[]
|
||||
[account_id]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
class REST::AnnualReportSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :year, :data, :schema_version, :share_url
|
||||
attributes :year, :data, :schema_version, :share_url, :account_id
|
||||
|
||||
def share_url
|
||||
public_wrapstodon_url(object.account, object.year, object.share_key) if object.share_key.present?
|
||||
end
|
||||
|
||||
def account_id
|
||||
object.account_id.to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,8 +9,4 @@ class REST::BaseCollectionSerializer < ActiveModel::Serializer
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def item_count
|
||||
object.respond_to?(:item_count) ? object.item_count : object.collection_items.count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
= opengraph 'og:site_name', site_title
|
||||
= opengraph 'profile:username', acct(@account)[1..]
|
||||
|
||||
= vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
|
||||
= flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
|
||||
|
||||
- content_for :html_classes, 'theme-dark'
|
||||
|
||||
#wrapstodon
|
||||
= render_wrapstodon_share_data @generated_annual_report
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddItemCountToCollections < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :collections, :item_count, :integer, default: 0, null: false
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_02_140424) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_09_093813) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@@ -380,6 +380,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_02_140424) do
|
||||
t.integer "original_number_of_items"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "item_count", default: 0, null: false
|
||||
t.index ["account_id"], name: "index_collections_on_account_id"
|
||||
t.index ["tag_id"], name: "index_collections_on_tag_id"
|
||||
end
|
||||
|
||||
19
spec/requests/wrapstodon_spec.rb
Normal file
19
spec/requests/wrapstodon_spec.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wrapstodon' do
|
||||
let(:generated_annual_report) { AnnualReport.new(user.account, Time.current.year).generate }
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
describe 'GET /@:account_username/wrapstodon/:year/:share_key' do
|
||||
context 'when share_key is invalid' do
|
||||
it 'returns not found' do
|
||||
get public_wrapstodon_path(account_username: user.account.username, year: generated_annual_report.year, share_key: 'sharks')
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -38,20 +38,4 @@ RSpec.describe REST::BaseCollectionSerializer do
|
||||
'updated_at' => match_api_datetime_format
|
||||
)
|
||||
end
|
||||
|
||||
describe 'Counting items' do
|
||||
before do
|
||||
Fabricate.times(2, :collection_item, collection:)
|
||||
end
|
||||
|
||||
it 'can count items on demand' do
|
||||
expect(subject['item_count']).to eq 2
|
||||
end
|
||||
|
||||
it 'can use precalculated counts' do
|
||||
collection.define_singleton_method :item_count, -> { 8 }
|
||||
|
||||
expect(subject['item_count']).to eq 8
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
22
spec/system/wrapstodon_spec.rb
Normal file
22
spec/system/wrapstodon_spec.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wrapstodon' do
|
||||
describe 'Viewing a wrapstodon' do
|
||||
let(:generated_annual_report) { AnnualReport.new(user.account, Time.current.year).generate }
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
context 'when signed in' do
|
||||
before { sign_in user }
|
||||
|
||||
it 'visits the wrap page and renders the web app' do
|
||||
visit public_wrapstodon_path(account_username: user.account.username, year: generated_annual_report.year, share_key: generated_annual_report.share_key)
|
||||
|
||||
expect(page)
|
||||
.to have_css('#wrapstodon')
|
||||
.and have_private_cache_control
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -86,7 +86,6 @@ WORKDIR /opt/mastodon
|
||||
|
||||
# Copy Node package configuration files from build system to container
|
||||
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
|
||||
COPY .yarn /opt/mastodon/.yarn
|
||||
# Copy Streaming source code from build system to container
|
||||
COPY ./streaming /opt/mastodon/streaming
|
||||
|
||||
|
||||
Reference in New Issue
Block a user