mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-15 08:48:53 +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)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.26.1)
|
hiredis-client (0.26.2)
|
||||||
redis-client (= 0.26.1)
|
redis-client (= 0.26.2)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
@@ -469,7 +469,7 @@ GEM
|
|||||||
nokogiri (1.18.10)
|
nokogiri (1.18.10)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.12)
|
oj (3.16.13)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.4)
|
omniauth (2.1.4)
|
||||||
@@ -703,7 +703,7 @@ GEM
|
|||||||
reline
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.26.1)
|
redis-client (0.26.2)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||||||
def set_collections
|
def set_collections
|
||||||
@collections = @account.collections
|
@collections = @account.collections
|
||||||
.with_tag
|
.with_tag
|
||||||
.with_item_count
|
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.offset(offset_param)
|
.offset(offset_param)
|
||||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def html_classes
|
def html_classes
|
||||||
output = []
|
output = []
|
||||||
|
output << content_for(:html_classes)
|
||||||
output << 'system-font' if current_account&.user&.setting_system_font_ui
|
output << 'system-font' if current_account&.user&.setting_system_font_ui
|
||||||
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
|
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
|
||||||
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')
|
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 { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
import {
|
import { importFetchedStatuses } from '@/mastodon/actions/importer';
|
||||||
importFetchedAccounts,
|
import { hydrateStore } from '@/mastodon/actions/store';
|
||||||
importFetchedStatuses,
|
|
||||||
} from '@/mastodon/actions/importer';
|
|
||||||
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
|
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
|
||||||
import { Router } from '@/mastodon/components/router';
|
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 { IntlProvider, loadLocale } from '@/mastodon/locales';
|
||||||
import { loadPolyfills } from '@/mastodon/polyfills';
|
import { loadPolyfills } from '@/mastodon/polyfills';
|
||||||
import ready from '@/mastodon/ready';
|
import ready from '@/mastodon/ready';
|
||||||
@@ -33,7 +31,14 @@ function loaded() {
|
|||||||
if (!report) {
|
if (!report) {
|
||||||
throw new Error('Initial state report not found');
|
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(importFetchedStatuses(initialState.statuses));
|
||||||
|
|
||||||
store.dispatch(setReport(report));
|
store.dispatch(setReport(report));
|
||||||
@@ -43,7 +48,7 @@ function loaded() {
|
|||||||
<IntlProvider>
|
<IntlProvider>
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<Router>
|
<Router>
|
||||||
<WrapstodonShare />
|
<WrapstodonSharedPage />
|
||||||
</Router>
|
</Router>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function hydrateStore(rawState) {
|
|||||||
|
|
||||||
dispatch(hydrateCompose());
|
dispatch(hydrateCompose());
|
||||||
dispatch(hydrateSearch());
|
dispatch(hydrateSearch());
|
||||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
if (rawState.accounts) {
|
||||||
|
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||||
|
}
|
||||||
dispatch(saveSettings());
|
dispatch(saveSettings());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
|
showActions: PropTypes.bool,
|
||||||
prepend: PropTypes.string,
|
prepend: PropTypes.string,
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
@@ -465,7 +466,8 @@ class Status extends ImmutablePureComponent {
|
|||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
notification,
|
notification,
|
||||||
history,
|
history,
|
||||||
isQuotedPost,
|
showActions = true,
|
||||||
|
isQuotedPost = false,
|
||||||
...other
|
...other
|
||||||
} = this.props;
|
} = this.props;
|
||||||
let attachments = null;
|
let attachments = null;
|
||||||
@@ -763,7 +765,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
{/* This is a glitch-soc addition to have a placeholder */}
|
{/* This is a glitch-soc addition to have a placeholder */}
|
||||||
{!expanded && <MentionsPlaceholder status={status} />}
|
{!expanded && <MentionsPlaceholder status={status} />}
|
||||||
|
|
||||||
{!isQuotedPost &&
|
{(showActions && !isQuotedPost) &&
|
||||||
<StatusActionBar
|
<StatusActionBar
|
||||||
status={status}
|
status={status}
|
||||||
account={status.get('account')}
|
account={status.get('account')}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
import {
|
import { importFetchedStatuses } from '@/flavours/glitch/actions/importer';
|
||||||
importFetchedAccounts,
|
import { hydrateStore } from '@/flavours/glitch/actions/store';
|
||||||
importFetchedStatuses,
|
|
||||||
} from '@/flavours/glitch/actions/importer';
|
|
||||||
import type { ApiAnnualReportResponse } from '@/flavours/glitch/api/annual_report';
|
import type { ApiAnnualReportResponse } from '@/flavours/glitch/api/annual_report';
|
||||||
import { Router } from '@/flavours/glitch/components/router';
|
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 { IntlProvider, loadLocale } from '@/flavours/glitch/locales';
|
||||||
import { loadPolyfills } from '@/flavours/glitch/polyfills';
|
import { loadPolyfills } from '@/flavours/glitch/polyfills';
|
||||||
import ready from '@/flavours/glitch/ready';
|
import ready from '@/flavours/glitch/ready';
|
||||||
@@ -33,7 +31,14 @@ function loaded() {
|
|||||||
if (!report) {
|
if (!report) {
|
||||||
throw new Error('Initial state report not found');
|
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(importFetchedStatuses(initialState.statuses));
|
||||||
|
|
||||||
store.dispatch(setReport(report));
|
store.dispatch(setReport(report));
|
||||||
@@ -43,7 +48,7 @@ function loaded() {
|
|||||||
<IntlProvider>
|
<IntlProvider>
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<Router>
|
<Router>
|
||||||
<WrapstodonShare />
|
<WrapstodonSharedPage />
|
||||||
</Router>
|
</Router>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Button } from '@/flavours/glitch/components/button';
|
import { Button } from '@/flavours/glitch/components/button';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
@@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{
|
|||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||||
<h2>
|
<h2>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.announcement.title'
|
id='annual_report.announcement.title'
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-on-media);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-media-base);
|
background: var(--color-bg-primary);
|
||||||
background:
|
background:
|
||||||
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
||||||
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
||||||
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
||||||
radial-gradient(at 16% 95%, #1e948299 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);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
h2 {
|
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 booster from '@/images/archetypes/booster.png';
|
||||||
import lurker from '@/images/archetypes/lurker.png';
|
import lurker from '@/images/archetypes/lurker.png';
|
||||||
import oracle from '@/images/archetypes/oracle.png';
|
import oracle from '@/images/archetypes/oracle.png';
|
||||||
import pollster from '@/images/archetypes/pollster.png';
|
import pollster from '@/images/archetypes/pollster.png';
|
||||||
import replier from '@/images/archetypes/replier.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>({
|
export const archetypeNames = defineMessages<ArchetypeData>({
|
||||||
booster: {
|
booster: {
|
||||||
id: 'annual_report.summary.archetype.booster',
|
id: 'annual_report.summary.archetype.booster.name',
|
||||||
defaultMessage: 'The cool-hunter',
|
defaultMessage: 'The Archer',
|
||||||
},
|
},
|
||||||
replier: {
|
replier: {
|
||||||
id: 'annual_report.summary.archetype.replier',
|
id: 'annual_report.summary.archetype.replier.name',
|
||||||
defaultMessage: 'The social butterfly',
|
defaultMessage: 'The Butterfly',
|
||||||
},
|
},
|
||||||
pollster: {
|
pollster: {
|
||||||
id: 'annual_report.summary.archetype.pollster',
|
id: 'annual_report.summary.archetype.pollster.name',
|
||||||
defaultMessage: 'The pollster',
|
defaultMessage: 'The Wonderer',
|
||||||
},
|
},
|
||||||
lurker: {
|
lurker: {
|
||||||
id: 'annual_report.summary.archetype.lurker',
|
id: 'annual_report.summary.archetype.lurker.name',
|
||||||
defaultMessage: 'The lurker',
|
defaultMessage: 'The Stoic',
|
||||||
},
|
},
|
||||||
oracle: {
|
oracle: {
|
||||||
id: 'annual_report.summary.archetype.oracle',
|
id: 'annual_report.summary.archetype.oracle.name',
|
||||||
defaultMessage: 'The oracle',
|
defaultMessage: 'The Oracle',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Archetype: React.FC<{
|
export const archetypeSelfDescriptions = defineMessages<ArchetypeData>({
|
||||||
data: ArchetypeData;
|
booster: {
|
||||||
}> = ({ data }) => {
|
id: 'annual_report.summary.archetype.booster.desc_self',
|
||||||
const intl = useIntl();
|
defaultMessage:
|
||||||
let illustration;
|
'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) {
|
export const archetypePublicDescriptions = defineMessages<ArchetypeData>({
|
||||||
case 'booster':
|
booster: {
|
||||||
illustration = booster;
|
id: 'annual_report.summary.archetype.booster.desc_public',
|
||||||
break;
|
defaultMessage:
|
||||||
case 'replier':
|
'{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
|
||||||
illustration = replier;
|
},
|
||||||
break;
|
replier: {
|
||||||
case 'pollster':
|
id: 'annual_report.summary.archetype.replier.desc_public',
|
||||||
illustration = pollster;
|
defaultMessage:
|
||||||
break;
|
'{name} frequently replied to other people’s posts, pollinating Mastodon with new discussions.',
|
||||||
case 'lurker':
|
},
|
||||||
illustration = lurker;
|
pollster: {
|
||||||
break;
|
id: 'annual_report.summary.archetype.pollster.desc_public',
|
||||||
case 'oracle':
|
defaultMessage:
|
||||||
illustration = oracle;
|
'{name} created more polls than other post types, cultivating curiosity on Mastodon.',
|
||||||
break;
|
},
|
||||||
}
|
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 (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__archetype'>
|
<div
|
||||||
<div className='annual-report__summary__archetype__label'>
|
className={classNames(styles.box, styles.archetype)}
|
||||||
{intl.formatMessage(archetypeNames[data])}
|
// 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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,68 +1,24 @@
|
|||||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
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 styles from './index.module.scss';
|
||||||
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
|
|
||||||
|
|
||||||
export const Followers: React.FC<{
|
export const Followers: React.FC<{
|
||||||
data: TimeSeriesMonth[];
|
count: number;
|
||||||
total?: number;
|
}> = ({ count }) => {
|
||||||
}> = ({ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__followers'>
|
<div className={classNames(styles.box, styles.followers, styles.content)}>
|
||||||
<Sparklines data={cumulativeGraph} margin={0}>
|
<div className={styles.statLarge}>
|
||||||
<svg>
|
<FormattedNumber value={count} />
|
||||||
<defs>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<div className={styles.title}>
|
||||||
</Sparklines>
|
<FormattedMessage
|
||||||
|
id='annual_report.summary.followers.new_followers'
|
||||||
<div className='annual-report__summary__followers__foreground'>
|
defaultMessage='{count, plural, one {new follower} other {new followers}}'
|
||||||
<div className='annual-report__summary__followers__number'>
|
values={{ count }}
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,106 +1,77 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
||||||
@typescript-eslint/no-explicit-any,
|
@typescript-eslint/no-explicit-any,
|
||||||
@typescript-eslint/no-unsafe-assignment */
|
@typescript-eslint/no-unsafe-assignment,
|
||||||
|
@typescript-eslint/no-unsafe-member-access,
|
||||||
import { useCallback } from 'react';
|
@typescript-eslint/no-unsafe-call */
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
import classNames from 'classnames';
|
||||||
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
|
||||||
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
|
import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
|
||||||
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
|
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
|
||||||
import {
|
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||||
makeGetStatus,
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
makeGetPictureInPicture,
|
|
||||||
} from 'flavours/glitch/selectors';
|
import styles from './index.module.scss';
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
|
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<{
|
export const HighlightedPost: React.FC<{
|
||||||
data: TopStatuses;
|
data: TopStatuses;
|
||||||
}> = ({ data }) => {
|
}> = ({ data }) => {
|
||||||
let statusId, label;
|
const { by_reblogs, by_favourites, by_replies } = data;
|
||||||
|
|
||||||
if (data.by_reblogs) {
|
const statusId = by_reblogs || by_favourites || by_replies;
|
||||||
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 dispatch = useAppDispatch();
|
|
||||||
const domain = useAppSelector((state) => state.meta.get('domain'));
|
|
||||||
const status = useAppSelector((state) =>
|
const status = useAppSelector((state) =>
|
||||||
statusId ? getStatus(state, { id: statusId }) : undefined,
|
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) {
|
if (!status) {
|
||||||
return (
|
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
|
<div className={classNames(styles.box, styles.mostBoostedPost)}>
|
||||||
<DetailedStatus
|
<div className={styles.content}>
|
||||||
status={status}
|
<h2 className={styles.title}>
|
||||||
pictureInPicture={pictureInPicture}
|
<FormattedMessage
|
||||||
domain={domain}
|
id='annual_report.summary.highlighted_post.title'
|
||||||
onToggleHidden={handleToggleHidden}
|
defaultMessage='Most popular post'
|
||||||
overrideDisplayName={displayName}
|
/>
|
||||||
expanded={false}
|
</h2>
|
||||||
/>
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusQuoteManager showActions={false} id={`${statusId}`} />
|
||||||
</div>
|
</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 type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
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 { 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 { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
import { me } from '@/flavours/glitch/initial_state';
|
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 { 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 { Followers } from './followers';
|
||||||
import { HighlightedPost } from './highlighted_post';
|
import { HighlightedPost } from './highlighted_post';
|
||||||
|
import styles from './index.module.scss';
|
||||||
import { MostUsedHashtag } from './most_used_hashtag';
|
import { MostUsedHashtag } from './most_used_hashtag';
|
||||||
import { NewPosts } from './new_posts';
|
import { NewPosts } from './new_posts';
|
||||||
|
|
||||||
const shareMessage = defineMessage({
|
const moduleClassNames = classNames.bind(styles);
|
||||||
|
|
||||||
|
export const shareMessage = defineMessage({
|
||||||
id: 'annual_report.summary.share_message',
|
id: 'annual_report.summary.share_message',
|
||||||
defaultMessage: 'I got the {archetype} archetype!',
|
defaultMessage: 'I got the {archetype} archetype!',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Share = false when using the embedded version of the report.
|
// Share = false when using the embedded version of the report.
|
||||||
export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => {
|
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||||
const currentAccount = useAppSelector((state) =>
|
context = 'standalone',
|
||||||
me ? state.accounts.get(me) : undefined,
|
}) => {
|
||||||
);
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const report = useAppSelector((state) => state.annualReport.report);
|
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) {
|
if (!report) {
|
||||||
return <LoadingIndicator />;
|
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 (
|
return (
|
||||||
<div className='annual-report'>
|
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||||
<div className='annual-report__header'>
|
<div className={styles.header}>
|
||||||
<h1>
|
<h1>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.thanks'
|
id='annual_report.summary.title'
|
||||||
defaultMessage='Thanks for being part of Mastodon!'
|
defaultMessage='Wrapstodon {year}'
|
||||||
/>
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id='annual_report.summary.here_it_is'
|
|
||||||
defaultMessage='Here is your {year} in review:'
|
|
||||||
values={{ year: report.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>
|
||||||
|
|
||||||
<div className='annual-report__bento annual-report__summary'>
|
<div className={styles.stack}>
|
||||||
<Archetype data={report.data.archetype} />
|
|
||||||
<HighlightedPost data={report.data.top_statuses} />
|
<HighlightedPost data={report.data.top_statuses} />
|
||||||
<Followers
|
<div
|
||||||
data={report.data.time_series}
|
className={moduleClassNames(styles.statsGrid, {
|
||||||
total={currentAccount?.followers_count}
|
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>
|
||||||
</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 { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
|
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
|
||||||
|
|
||||||
export const MostUsedHashtag: React.FC<{
|
import styles from './index.module.scss';
|
||||||
data: NameAndCount[];
|
|
||||||
}> = ({ data }) => {
|
|
||||||
const hashtag = data[0];
|
|
||||||
|
|
||||||
|
export const MostUsedHashtag: React.FC<{
|
||||||
|
hashtag: NameAndCount;
|
||||||
|
}> = ({ hashtag }) => {
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
|
<div
|
||||||
<div className='annual-report__summary__most-used-hashtag__hashtag'>
|
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
|
||||||
{hashtag ? (
|
>
|
||||||
<>#{hashtag.name}</>
|
<div className={styles.title}>
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id='annual_report.summary.most_used_hashtag.none'
|
|
||||||
defaultMessage='None'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='annual-report__summary__most-used-hashtag__label'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
|
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
|
||||||
defaultMessage='most used hashtag'
|
defaultMessage='Most used hashtag'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +1,23 @@
|
|||||||
import { FormattedNumber, FormattedMessage } from 'react-intl';
|
import { FormattedNumber, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
|
import classNames from 'classnames';
|
||||||
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
|
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export const NewPosts: React.FC<{
|
export const NewPosts: React.FC<{
|
||||||
data: TimeSeriesMonth[];
|
count: number;
|
||||||
}> = ({ data }) => {
|
}> = ({ count }) => {
|
||||||
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__new-posts'>
|
<div className={classNames(styles.box, styles.newPosts, styles.content)}>
|
||||||
<svg width={500} height={500}>
|
<div className={styles.statLarge}>
|
||||||
<defs>
|
<FormattedNumber value={count} />
|
||||||
<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>
|
</div>
|
||||||
<div className='annual-report__summary__new-posts__label'>
|
|
||||||
|
<div className={styles.title}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.new_posts.new_posts'
|
id='annual_report.summary.new_posts.new_posts'
|
||||||
defaultMessage='new posts'
|
defaultMessage='{count, plural, one {new post} other {new posts}}'
|
||||||
|
values={{ count }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
.wrapper {
|
||||||
max-width: 40rem;
|
max-width: max-content;
|
||||||
margin: 0 auto;
|
margin: 40px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@@ -3,12 +3,12 @@ import type { FC } from 'react';
|
|||||||
import { IconLogo } from '@/flavours/glitch/components/logo';
|
import { IconLogo } from '@/flavours/glitch/components/logo';
|
||||||
|
|
||||||
import { AnnualReport } from './index';
|
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 (
|
return (
|
||||||
<main className={classes.wrapper}>
|
<main className={classes.wrapper}>
|
||||||
<AnnualReport share={false} />
|
<AnnualReport />
|
||||||
<footer className={classes.footer}>
|
<footer className={classes.footer}>
|
||||||
<IconLogo className={classes.logo} />
|
<IconLogo className={classes.logo} />
|
||||||
Generated with ♥ by the Mastodon team
|
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 () {
|
export function AnnualReportModal () {
|
||||||
return import('../components/annual_report_modal');
|
return import('../../annual_report/modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListEdit () {
|
export function ListEdit () {
|
||||||
|
|||||||
@@ -55,5 +55,6 @@ export type AnnualReport = {
|
|||||||
schema_version: 2;
|
schema_version: 2;
|
||||||
data: AnnualReportV2;
|
data: AnnualReportV2;
|
||||||
share_url: string | null;
|
share_url: string | null;
|
||||||
|
account_id: string;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
import type { ApiReportJSON } from 'flavours/glitch/api_types/reports';
|
import type { ApiReportJSON } from 'flavours/glitch/api_types/reports';
|
||||||
|
|
||||||
// Maximum number of avatars displayed in a notification group
|
// 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;
|
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
|
||||||
|
|
||||||
interface BaseNotificationGroup
|
interface BaseNotificationGroup
|
||||||
|
|||||||
@@ -442,7 +442,9 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
|
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
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:
|
case COMPOSE_MOUNT:
|
||||||
return state
|
return state
|
||||||
.set('mounted', state.get('mounted') + 1)
|
.set('mounted', state.get('mounted') + 1)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
@use 'mastodon/polls';
|
@use 'mastodon/polls';
|
||||||
@use 'mastodon/modal';
|
@use 'mastodon/modal';
|
||||||
@use 'mastodon/emoji_picker';
|
@use 'mastodon/emoji_picker';
|
||||||
@use 'mastodon/annual_reports';
|
|
||||||
@use 'mastodon/about';
|
@use 'mastodon/about';
|
||||||
@use 'mastodon/tables';
|
@use 'mastodon/tables';
|
||||||
@use 'mastodon/admin';
|
@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(hydrateCompose());
|
||||||
dispatch(hydrateSearch());
|
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,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
showThread: PropTypes.bool,
|
showThread: PropTypes.bool,
|
||||||
|
showActions: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
shouldHighlightOnMount: PropTypes.bool,
|
shouldHighlightOnMount: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
@@ -381,7 +382,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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;
|
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} />
|
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
@@ -12,7 +14,7 @@ export const AnnualReportAnnouncement: React.FC<{
|
|||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
}> = ({ year, hasData, isLoading, onRequestBuild, onOpen }) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||||
<h2>
|
<h2>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.announcement.title'
|
id='annual_report.announcement.title'
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-on-media);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-media-base);
|
background: var(--color-bg-primary);
|
||||||
background:
|
background:
|
||||||
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
radial-gradient(at 40% 87%, #240c9a99 0, transparent 50%),
|
||||||
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
radial-gradient(at 19% 10%, #6b0c9a99 0, transparent 50%),
|
||||||
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
radial-gradient(at 90% 27%, #9a0c8299 0, transparent 50%),
|
||||||
radial-gradient(at 16% 95%, #1e948299 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);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
h2 {
|
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 booster from '@/images/archetypes/booster.png';
|
||||||
import lurker from '@/images/archetypes/lurker.png';
|
import lurker from '@/images/archetypes/lurker.png';
|
||||||
import oracle from '@/images/archetypes/oracle.png';
|
import oracle from '@/images/archetypes/oracle.png';
|
||||||
import pollster from '@/images/archetypes/pollster.png';
|
import pollster from '@/images/archetypes/pollster.png';
|
||||||
import replier from '@/images/archetypes/replier.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>({
|
export const archetypeNames = defineMessages<ArchetypeData>({
|
||||||
booster: {
|
booster: {
|
||||||
id: 'annual_report.summary.archetype.booster',
|
id: 'annual_report.summary.archetype.booster.name',
|
||||||
defaultMessage: 'The cool-hunter',
|
defaultMessage: 'The Archer',
|
||||||
},
|
},
|
||||||
replier: {
|
replier: {
|
||||||
id: 'annual_report.summary.archetype.replier',
|
id: 'annual_report.summary.archetype.replier.name',
|
||||||
defaultMessage: 'The social butterfly',
|
defaultMessage: 'The Butterfly',
|
||||||
},
|
},
|
||||||
pollster: {
|
pollster: {
|
||||||
id: 'annual_report.summary.archetype.pollster',
|
id: 'annual_report.summary.archetype.pollster.name',
|
||||||
defaultMessage: 'The pollster',
|
defaultMessage: 'The Wonderer',
|
||||||
},
|
},
|
||||||
lurker: {
|
lurker: {
|
||||||
id: 'annual_report.summary.archetype.lurker',
|
id: 'annual_report.summary.archetype.lurker.name',
|
||||||
defaultMessage: 'The lurker',
|
defaultMessage: 'The Stoic',
|
||||||
},
|
},
|
||||||
oracle: {
|
oracle: {
|
||||||
id: 'annual_report.summary.archetype.oracle',
|
id: 'annual_report.summary.archetype.oracle.name',
|
||||||
defaultMessage: 'The oracle',
|
defaultMessage: 'The Oracle',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Archetype: React.FC<{
|
export const archetypeSelfDescriptions = defineMessages<ArchetypeData>({
|
||||||
data: ArchetypeData;
|
booster: {
|
||||||
}> = ({ data }) => {
|
id: 'annual_report.summary.archetype.booster.desc_self',
|
||||||
const intl = useIntl();
|
defaultMessage:
|
||||||
let illustration;
|
'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) {
|
export const archetypePublicDescriptions = defineMessages<ArchetypeData>({
|
||||||
case 'booster':
|
booster: {
|
||||||
illustration = booster;
|
id: 'annual_report.summary.archetype.booster.desc_public',
|
||||||
break;
|
defaultMessage:
|
||||||
case 'replier':
|
'{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.',
|
||||||
illustration = replier;
|
},
|
||||||
break;
|
replier: {
|
||||||
case 'pollster':
|
id: 'annual_report.summary.archetype.replier.desc_public',
|
||||||
illustration = pollster;
|
defaultMessage:
|
||||||
break;
|
'{name} frequently replied to other people’s posts, pollinating Mastodon with new discussions.',
|
||||||
case 'lurker':
|
},
|
||||||
illustration = lurker;
|
pollster: {
|
||||||
break;
|
id: 'annual_report.summary.archetype.pollster.desc_public',
|
||||||
case 'oracle':
|
defaultMessage:
|
||||||
illustration = oracle;
|
'{name} created more polls than other post types, cultivating curiosity on Mastodon.',
|
||||||
break;
|
},
|
||||||
}
|
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 (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__archetype'>
|
<div
|
||||||
<div className='annual-report__summary__archetype__label'>
|
className={classNames(styles.box, styles.archetype)}
|
||||||
{intl.formatMessage(archetypeNames[data])}
|
// 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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,68 +1,24 @@
|
|||||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
|
|
||||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import styles from './index.module.scss';
|
||||||
import type { TimeSeriesMonth } from 'mastodon/models/annual_report';
|
|
||||||
|
|
||||||
export const Followers: React.FC<{
|
export const Followers: React.FC<{
|
||||||
data: TimeSeriesMonth[];
|
count: number;
|
||||||
total?: number;
|
}> = ({ count }) => {
|
||||||
}> = ({ 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__followers'>
|
<div className={classNames(styles.box, styles.followers, styles.content)}>
|
||||||
<Sparklines data={cumulativeGraph} margin={0}>
|
<div className={styles.statLarge}>
|
||||||
<svg>
|
<FormattedNumber value={count} />
|
||||||
<defs>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<div className={styles.title}>
|
||||||
</Sparklines>
|
<FormattedMessage
|
||||||
|
id='annual_report.summary.followers.new_followers'
|
||||||
<div className='annual-report__summary__followers__foreground'>
|
defaultMessage='{count, plural, one {new follower} other {new followers}}'
|
||||||
<div className='annual-report__summary__followers__number'>
|
values={{ count }}
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,102 +1,77 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
||||||
@typescript-eslint/no-explicit-any,
|
@typescript-eslint/no-explicit-any,
|
||||||
@typescript-eslint/no-unsafe-assignment */
|
@typescript-eslint/no-unsafe-assignment,
|
||||||
|
@typescript-eslint/no-unsafe-member-access,
|
||||||
import { useCallback } from 'react';
|
@typescript-eslint/no-unsafe-call */
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { DisplayName } from '@/mastodon/components/display_name';
|
import classNames from 'classnames';
|
||||||
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
|
||||||
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import type { TopStatuses } from 'mastodon/models/annual_report';
|
import type { TopStatuses } from 'mastodon/models/annual_report';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
|
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<{
|
export const HighlightedPost: React.FC<{
|
||||||
data: TopStatuses;
|
data: TopStatuses;
|
||||||
}> = ({ data }) => {
|
}> = ({ data }) => {
|
||||||
let statusId, label;
|
const { by_reblogs, by_favourites, by_replies } = data;
|
||||||
|
|
||||||
if (data.by_reblogs) {
|
const statusId = by_reblogs || by_favourites || by_replies;
|
||||||
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 dispatch = useAppDispatch();
|
|
||||||
const domain = useAppSelector((state) => state.meta.get('domain'));
|
|
||||||
const status = useAppSelector((state) =>
|
const status = useAppSelector((state) =>
|
||||||
statusId ? getStatus(state, { id: statusId }) : undefined,
|
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) {
|
if (!status) {
|
||||||
return (
|
return <div className={classNames(styles.box, styles.mostBoostedPost)} />;
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
|
<div className={classNames(styles.box, styles.mostBoostedPost)}>
|
||||||
<DetailedStatus
|
<div className={styles.content}>
|
||||||
status={status}
|
<h2 className={styles.title}>
|
||||||
pictureInPicture={pictureInPicture}
|
<FormattedMessage
|
||||||
domain={domain}
|
id='annual_report.summary.highlighted_post.title'
|
||||||
onToggleHidden={handleToggleHidden}
|
defaultMessage='Most popular post'
|
||||||
overrideDisplayName={displayName}
|
/>
|
||||||
/>
|
</h2>
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusQuoteManager showActions={false} id={`${statusId}`} />
|
||||||
</div>
|
</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 type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
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 { 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 { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
import { me } from '@/mastodon/initial_state';
|
import { me } from '@/mastodon/initial_state';
|
||||||
import type { AnnualReport as AnnualReportData } from '@/mastodon/models/annual_report';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
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 { Followers } from './followers';
|
||||||
import { HighlightedPost } from './highlighted_post';
|
import { HighlightedPost } from './highlighted_post';
|
||||||
|
import styles from './index.module.scss';
|
||||||
import { MostUsedHashtag } from './most_used_hashtag';
|
import { MostUsedHashtag } from './most_used_hashtag';
|
||||||
import { NewPosts } from './new_posts';
|
import { NewPosts } from './new_posts';
|
||||||
|
|
||||||
const shareMessage = defineMessage({
|
const moduleClassNames = classNames.bind(styles);
|
||||||
|
|
||||||
|
export const shareMessage = defineMessage({
|
||||||
id: 'annual_report.summary.share_message',
|
id: 'annual_report.summary.share_message',
|
||||||
defaultMessage: 'I got the {archetype} archetype!',
|
defaultMessage: 'I got the {archetype} archetype!',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Share = false when using the embedded version of the report.
|
// Share = false when using the embedded version of the report.
|
||||||
export const AnnualReport: FC<{ share?: boolean }> = ({ share = true }) => {
|
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||||
const currentAccount = useAppSelector((state) =>
|
context = 'standalone',
|
||||||
me ? state.accounts.get(me) : undefined,
|
}) => {
|
||||||
);
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const report = useAppSelector((state) => state.annualReport.report);
|
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) {
|
if (!report) {
|
||||||
return <LoadingIndicator />;
|
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 (
|
return (
|
||||||
<div className='annual-report'>
|
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||||
<div className='annual-report__header'>
|
<div className={styles.header}>
|
||||||
<h1>
|
<h1>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.thanks'
|
id='annual_report.summary.title'
|
||||||
defaultMessage='Thanks for being part of Mastodon!'
|
defaultMessage='Wrapstodon {year}'
|
||||||
/>
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
<FormattedMessage
|
|
||||||
id='annual_report.summary.here_it_is'
|
|
||||||
defaultMessage='Here is your {year} in review:'
|
|
||||||
values={{ year: report.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>
|
||||||
|
|
||||||
<div className='annual-report__bento annual-report__summary'>
|
<div className={styles.stack}>
|
||||||
<Archetype data={report.data.archetype} />
|
|
||||||
<HighlightedPost data={report.data.top_statuses} />
|
<HighlightedPost data={report.data.top_statuses} />
|
||||||
<Followers
|
<div
|
||||||
data={report.data.time_series}
|
className={moduleClassNames(styles.statsGrid, {
|
||||||
total={currentAccount?.followers_count}
|
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>
|
||||||
</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 { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import type { NameAndCount } from 'mastodon/models/annual_report';
|
import type { NameAndCount } from 'mastodon/models/annual_report';
|
||||||
|
|
||||||
export const MostUsedHashtag: React.FC<{
|
import styles from './index.module.scss';
|
||||||
data: NameAndCount[];
|
|
||||||
}> = ({ data }) => {
|
|
||||||
const hashtag = data[0];
|
|
||||||
|
|
||||||
|
export const MostUsedHashtag: React.FC<{
|
||||||
|
hashtag: NameAndCount;
|
||||||
|
}> = ({ hashtag }) => {
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
|
<div
|
||||||
<div className='annual-report__summary__most-used-hashtag__hashtag'>
|
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
|
||||||
{hashtag ? (
|
>
|
||||||
<>#{hashtag.name}</>
|
<div className={styles.title}>
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id='annual_report.summary.most_used_hashtag.none'
|
|
||||||
defaultMessage='None'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='annual-report__summary__most-used-hashtag__label'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
|
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
|
||||||
defaultMessage='most used hashtag'
|
defaultMessage='Most used hashtag'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +1,23 @@
|
|||||||
import { FormattedNumber, FormattedMessage } from 'react-intl';
|
import { FormattedNumber, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
|
import classNames from 'classnames';
|
||||||
import type { TimeSeriesMonth } from 'mastodon/models/annual_report';
|
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export const NewPosts: React.FC<{
|
export const NewPosts: React.FC<{
|
||||||
data: TimeSeriesMonth[];
|
count: number;
|
||||||
}> = ({ data }) => {
|
}> = ({ count }) => {
|
||||||
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='annual-report__bento__box annual-report__summary__new-posts'>
|
<div className={classNames(styles.box, styles.newPosts, styles.content)}>
|
||||||
<svg width={500} height={500}>
|
<div className={styles.statLarge}>
|
||||||
<defs>
|
<FormattedNumber value={count} />
|
||||||
<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>
|
</div>
|
||||||
<div className='annual-report__summary__new-posts__label'>
|
|
||||||
|
<div className={styles.title}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='annual_report.summary.new_posts.new_posts'
|
id='annual_report.summary.new_posts.new_posts'
|
||||||
defaultMessage='new posts'
|
defaultMessage='{count, plural, one {new post} other {new posts}}'
|
||||||
|
values={{ count }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
.wrapper {
|
||||||
max-width: 40rem;
|
max-width: max-content;
|
||||||
margin: 0 auto;
|
margin: 40px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@@ -3,12 +3,12 @@ import type { FC } from 'react';
|
|||||||
import { IconLogo } from '@/mastodon/components/logo';
|
import { IconLogo } from '@/mastodon/components/logo';
|
||||||
|
|
||||||
import { AnnualReport } from './index';
|
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 (
|
return (
|
||||||
<main className={classes.wrapper}>
|
<main className={classes.wrapper}>
|
||||||
<AnnualReport share={false} />
|
<AnnualReport />
|
||||||
<footer className={classes.footer}>
|
<footer className={classes.footer}>
|
||||||
<IconLogo className={classes.logo} />
|
<IconLogo className={classes.logo} />
|
||||||
Generated with ♥ by the Mastodon team
|
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 () {
|
export function AnnualReportModal () {
|
||||||
return import('../components/annual_report_modal');
|
return import('../../annual_report/modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListEdit () {
|
export function ListEdit () {
|
||||||
|
|||||||
@@ -117,26 +117,40 @@
|
|||||||
"annual_report.announcement.action_view": "View my Wrapstodon",
|
"annual_report.announcement.action_view": "View my Wrapstodon",
|
||||||
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
|
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
|
||||||
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
|
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
|
||||||
"annual_report.summary.archetype.booster": "The cool-hunter",
|
"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.lurker": "The lurker",
|
"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.oracle": "The oracle",
|
"annual_report.summary.archetype.booster.name": "The Archer",
|
||||||
"annual_report.summary.archetype.pollster": "The pollster",
|
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||||
"annual_report.summary.archetype.replier": "The social butterfly",
|
"annual_report.summary.archetype.lurker.desc_public": "We know {name} was out there, somewhere, enjoying Mastodon in their own quiet way.",
|
||||||
"annual_report.summary.followers.followers": "followers",
|
"annual_report.summary.archetype.lurker.desc_self": "We know you were out there, somewhere, enjoying Mastodon in your own quiet way.",
|
||||||
"annual_report.summary.followers.total": "{count} total",
|
"annual_report.summary.archetype.lurker.name": "The Stoic",
|
||||||
"annual_report.summary.here_it_is": "Here is your {year} in review:",
|
"annual_report.summary.archetype.oracle.desc_public": "{name} created new posts more than replies, keeping Mastodon fresh and future-facing.",
|
||||||
"annual_report.summary.highlighted_post.by_favourites": "most favourited post",
|
"annual_report.summary.archetype.oracle.desc_self": "You created new posts more than replies, keeping Mastodon fresh and future-facing.",
|
||||||
"annual_report.summary.highlighted_post.by_reblogs": "most boosted post",
|
"annual_report.summary.archetype.oracle.name": "The Oracle",
|
||||||
"annual_report.summary.highlighted_post.by_replies": "post with the most replies",
|
"annual_report.summary.archetype.pollster.desc_public": "{name} created more polls than other post types, cultivating curiosity on Mastodon.",
|
||||||
"annual_report.summary.highlighted_post.possessive": "{name}'s",
|
"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_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.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.new_posts.new_posts": "new posts",
|
||||||
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
|
"annual_report.summary.percentile.text": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>",
|
||||||
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
|
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
|
||||||
"annual_report.summary.share_message": "I got the {archetype} archetype!",
|
"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)",
|
"attachments_list.unprocessed": "(unprocessed)",
|
||||||
"audio.hide": "Hide audio",
|
"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.",
|
"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;
|
schema_version: 2;
|
||||||
data: AnnualReportV2;
|
data: AnnualReportV2;
|
||||||
share_url: string | null;
|
share_url: string | null;
|
||||||
|
account_id: string;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
import type { ApiReportJSON } from 'mastodon/api_types/reports';
|
import type { ApiReportJSON } from 'mastodon/api_types/reports';
|
||||||
|
|
||||||
// Maximum number of avatars displayed in a notification group
|
// 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;
|
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
|
||||||
|
|
||||||
interface BaseNotificationGroup
|
interface BaseNotificationGroup
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
|
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
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:
|
case COMPOSE_MOUNT:
|
||||||
return state
|
return state
|
||||||
.set('mounted', state.get('mounted') + 1)
|
.set('mounted', state.get('mounted') + 1)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
@use 'mastodon/polls';
|
@use 'mastodon/polls';
|
||||||
@use 'mastodon/modal';
|
@use 'mastodon/modal';
|
||||||
@use 'mastodon/emoji_picker';
|
@use 'mastodon/emoji_picker';
|
||||||
@use 'mastodon/annual_reports';
|
|
||||||
@use 'mastodon/about';
|
@use 'mastodon/about';
|
||||||
@use 'mastodon/tables';
|
@use 'mastodon/tables';
|
||||||
@use 'mastodon/admin';
|
@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
|
end
|
||||||
|
|
||||||
def redirect_uris
|
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:
|
# the database:
|
||||||
redirect_uri.split
|
redirect_uri.split
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class BulkImport < ApplicationRecord
|
|||||||
BulkImport.increment_counter(:processed_items, bulk_import_id)
|
BulkImport.increment_counter(:processed_items, bulk_import_id)
|
||||||
BulkImport.increment_counter(:imported_items, bulk_import_id) if imported
|
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 = BulkImport.find(bulk_import_id)
|
||||||
bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
|
bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# id :bigint(8) not null, primary key
|
# id :bigint(8) not null, primary key
|
||||||
# description :text not null
|
# description :text not null
|
||||||
# discoverable :boolean not null
|
# discoverable :boolean not null
|
||||||
|
# item_count :integer default(0), not null
|
||||||
# local :boolean not null
|
# local :boolean not null
|
||||||
# name :string not null
|
# name :string not null
|
||||||
# original_number_of_items :integer
|
# original_number_of_items :integer
|
||||||
@@ -39,11 +40,6 @@ class Collection < ApplicationRecord
|
|||||||
validate :items_do_not_exceed_limit
|
validate :items_do_not_exceed_limit
|
||||||
|
|
||||||
scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) }
|
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) }
|
scope :with_tag, -> { includes(:tag) }
|
||||||
|
|
||||||
def remote?
|
def remote?
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# collection_id :bigint(8) not null
|
# collection_id :bigint(8) not null
|
||||||
#
|
#
|
||||||
class CollectionItem < ApplicationRecord
|
class CollectionItem < ApplicationRecord
|
||||||
belongs_to :collection
|
belongs_to :collection, counter_cache: :item_count
|
||||||
belongs_to :account, optional: true
|
belongs_to :account, optional: true
|
||||||
|
|
||||||
enum :state,
|
enum :state,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class GeneratedAnnualReport < ApplicationRecord
|
|||||||
when 1
|
when 1
|
||||||
data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id')
|
data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id')
|
||||||
when 2
|
when 2
|
||||||
[]
|
[account_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
class REST::AnnualReportSerializer < ActiveModel::Serializer
|
class REST::AnnualReportSerializer < ActiveModel::Serializer
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
|
||||||
attributes :year, :data, :schema_version, :share_url
|
attributes :year, :data, :schema_version, :share_url, :account_id
|
||||||
|
|
||||||
def share_url
|
def share_url
|
||||||
public_wrapstodon_url(object.account, object.year, object.share_key) if object.share_key.present?
|
public_wrapstodon_url(object.account, object.year, object.share_key) if object.share_key.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def account_id
|
||||||
|
object.account_id.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,8 +9,4 @@ class REST::BaseCollectionSerializer < ActiveModel::Serializer
|
|||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def item_count
|
|
||||||
object.respond_to?(:item_count) ? object.item_count : object.collection_items.count
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
= opengraph 'og:site_name', site_title
|
= opengraph 'og:site_name', site_title
|
||||||
= opengraph 'profile:username', acct(@account)[1..]
|
= 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
|
#wrapstodon
|
||||||
= render_wrapstodon_share_data @generated_annual_report
|
= 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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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.integer "original_number_of_items"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 ["account_id"], name: "index_collections_on_account_id"
|
||||||
t.index ["tag_id"], name: "index_collections_on_tag_id"
|
t.index ["tag_id"], name: "index_collections_on_tag_id"
|
||||||
end
|
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
|
'updated_at' => match_api_datetime_format
|
||||||
)
|
)
|
||||||
end
|
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
|
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 Node package configuration files from build system to container
|
||||||
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
|
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 source code from build system to container
|
||||||
COPY ./streaming /opt/mastodon/streaming
|
COPY ./streaming /opt/mastodon/streaming
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user