[Glitch] Add year in review feature to web UI

Port d6349c0e9a to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2024-11-05 15:40:07 +01:00 committed by Claire
parent d19d7a283e
commit 3244926565
21 changed files with 1047 additions and 15 deletions

View file

@ -20,6 +20,7 @@ export const allNotificationTypes = [
'admin.report', 'admin.report',
'moderation_warning', 'moderation_warning',
'severed_relationships', 'severed_relationships',
'annual_report',
]; ];
export type NotificationWithStatusType = export type NotificationWithStatusType =
@ -37,7 +38,8 @@ export type NotificationType =
| 'moderation_warning' | 'moderation_warning'
| 'severed_relationships' | 'severed_relationships'
| 'admin.sign_up' | 'admin.sign_up'
| 'admin.report'; | 'admin.report'
| 'annual_report';
export interface BaseNotificationJSON { export interface BaseNotificationJSON {
id: string; id: string;
@ -130,6 +132,15 @@ interface AccountRelationshipSeveranceNotificationJSON
event: ApiAccountRelationshipSeveranceEventJSON; event: ApiAccountRelationshipSeveranceEventJSON;
} }
export interface ApiAnnualReportEventJSON {
year: string;
}
interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'annual_report';
annual_report: ApiAnnualReportEventJSON;
}
export type ApiNotificationJSON = export type ApiNotificationJSON =
| SimpleNotificationJSON | SimpleNotificationJSON
| ReportNotificationJSON | ReportNotificationJSON
@ -142,7 +153,8 @@ export type ApiNotificationGroupJSON =
| ReportNotificationGroupJSON | ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON | AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON | NotificationGroupWithStatusJSON
| ModerationWarningNotificationGroupJSON; | ModerationWarningNotificationGroupJSON
| AnnualReportNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON { export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[]; accounts: ApiAccountJSON[];

View file

@ -13,11 +13,14 @@ class ModalRoot extends PureComponent {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
backgroundColor: PropTypes.shape({ backgroundColor: PropTypes.oneOfType([
r: PropTypes.number, PropTypes.string,
g: PropTypes.number, PropTypes.shape({
b: PropTypes.number, r: PropTypes.number,
}), g: PropTypes.number,
b: PropTypes.number,
}),
]),
noEsc: PropTypes.bool, noEsc: PropTypes.bool,
ignoreFocus: PropTypes.bool, ignoreFocus: PropTypes.bool,
...WithOptionalRouterPropTypes, ...WithOptionalRouterPropTypes,
@ -146,14 +149,17 @@ class ModalRoot extends PureComponent {
let backgroundColor = null; let backgroundColor = null;
if (this.props.backgroundColor) { if (this.props.backgroundColor && typeof this.props.backgroundColor === 'string') {
backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); backgroundColor = this.props.backgroundColor;
} else if (this.props.backgroundColor) {
const darkenedColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
backgroundColor = `rgb(${darkenedColor.r}, ${darkenedColor.g}, ${darkenedColor.b})`;
} }
return ( return (
<div className='modal-root' ref={this.setRef}> <div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} /> <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor }} />
<div role='dialog' className='modal-root__container'>{children}</div> <div role='dialog' className='modal-root__container'>{children}</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,69 @@
import { FormattedMessage } from 'react-intl';
import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png';
import pollster from '@/images/archetypes/pollster.png';
import replier from '@/images/archetypes/replier.png';
import type { Archetype as ArchetypeData } from 'flavours/glitch/models/annual_report';
export const Archetype: React.FC<{
data: ArchetypeData;
}> = ({ data }) => {
let illustration, label;
switch (data) {
case 'booster':
illustration = booster;
label = (
<FormattedMessage
id='annual_report.summary.archetype.booster'
defaultMessage='The cool-hunter'
/>
);
break;
case 'replier':
illustration = replier;
label = (
<FormattedMessage
id='annual_report.summary.archetype.replier'
defaultMessage='The social butterfly'
/>
);
break;
case 'pollster':
illustration = pollster;
label = (
<FormattedMessage
id='annual_report.summary.archetype.pollster'
defaultMessage='The pollster'
/>
);
break;
case 'lurker':
illustration = lurker;
label = (
<FormattedMessage
id='annual_report.summary.archetype.lurker'
defaultMessage='The lurker'
/>
);
break;
case 'oracle':
illustration = oracle;
label = (
<FormattedMessage
id='annual_report.summary.archetype.oracle'
defaultMessage='The oracle'
/>
);
break;
}
return (
<div className='annual-report__bento__box annual-report__summary__archetype'>
<div className='annual-report__summary__archetype__label'>{label}</div>
<img src={illustration} alt='' />
</div>
);
};

View file

@ -0,0 +1,69 @@
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
export const Followers: React.FC<{
data: TimeSeriesMonth[];
total?: number;
}> = ({ data, total }) => {
const change = data.reduce((sum, item) => sum + item.followers, 0);
const cumulativeGraph = data.reduce(
(newData, item) => [
...newData,
item.followers + (newData[newData.length - 1] ?? 0),
],
[0],
);
return (
<div className='annual-report__bento__box annual-report__summary__followers'>
<Sparklines data={cumulativeGraph} margin={0}>
<svg>
<defs>
<linearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop
offset='0%'
stopColor='var(--sparkline-gradient-top)'
stopOpacity='1'
/>
<stop
offset='100%'
stopColor='var(--sparkline-gradient-bottom)'
stopOpacity='0'
/>
</linearGradient>
</defs>
</svg>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
<div className='annual-report__summary__followers__foreground'>
<div className='annual-report__summary__followers__number'>
{change > -1 ? '+' : '-'}
<FormattedNumber value={change} />
</div>
<div className='annual-report__summary__followers__label'>
<span>
<FormattedMessage
id='annual_report.summary.followers.followers'
defaultMessage='followers'
/>
</span>
<div className='annual-report__summary__followers__footnote'>
<FormattedMessage
id='annual_report.summary.followers.total'
defaultMessage='{count} total'
values={{ count: <ShortNumber value={total ?? 0} /> }}
/>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
import { me } from 'flavours/glitch/initial_state';
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
import {
makeGetStatus,
makeGetPictureInPicture,
} from 'flavours/glitch/selectors';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
let statusId, label;
if (data.by_reblogs) {
statusId = data.by_reblogs;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_reblogs'
defaultMessage='most boosted post'
/>
);
} else if (data.by_favourites) {
statusId = data.by_favourites;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_favourites'
defaultMessage='most favourited post'
/>
);
} else {
statusId = data.by_replies;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_replies'
defaultMessage='post with the most replies'
/>
);
}
const dispatch = useAppDispatch();
const domain = useAppSelector((state) => state.meta.get('domain'));
const status = useAppSelector((state) =>
statusId ? getStatus(state, { id: statusId }) : undefined,
);
const pictureInPicture = useAppSelector((state) =>
statusId ? getPictureInPicture(state, { id: statusId }) : undefined,
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
if (!status) {
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
);
}
const displayName = (
<span className='display-name'>
<strong className='display-name__html'>
<FormattedMessage
id='annual_report.summary.highlighted_post.possessive'
defaultMessage="{name}'s"
values={{
name: account && (
<bdi
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
),
}}
/>
</strong>
<span className='display-name__account'>{label}</span>
</span>
);
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
<DetailedStatus
status={status}
pictureInPicture={pictureInPicture}
domain={domain}
onToggleHidden={handleToggleHidden}
overrideDisplayName={displayName}
expanded={false}
/>
</div>
);
};

View file

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import {
importFetchedStatuses,
importFetchedAccounts,
} from 'flavours/glitch/actions/importer';
import { apiRequestGet, apiRequestPost } from 'flavours/glitch/api';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
import type { AnnualReport as AnnualReportData } from 'flavours/glitch/models/annual_report';
import type { Status } from 'flavours/glitch/models/status';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { Archetype } from './archetype';
import { Followers } from './followers';
import { HighlightedPost } from './highlighted_post';
import { MostUsedHashtag } from './most_used_hashtag';
import { NewPosts } from './new_posts';
import { Percentile } from './percentile';
interface AnnualReportResponse {
annual_reports: AnnualReportData[];
accounts: Account[];
statuses: Status[];
}
export const AnnualReport: React.FC<{
year: string;
}> = ({ year }) => {
const [response, setResponse] = useState<AnnualReportResponse | null>(null);
const [loading, setLoading] = useState(false);
const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const dispatch = useAppDispatch();
useEffect(() => {
setLoading(true);
apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`)
.then((data) => {
dispatch(importFetchedStatuses(data.statuses));
dispatch(importFetchedAccounts(data.accounts));
setResponse(data);
setLoading(false);
return apiRequestPost(`v1/annual_reports/${year}/read`);
})
.catch(() => {
setLoading(false);
});
}, [dispatch, year, setResponse, setLoading]);
if (loading) {
return <LoadingIndicator />;
}
const report = response?.annual_reports[0];
if (!report) {
return null;
}
return (
<div className='annual-report'>
<div className='annual-report__header'>
<h1>
<FormattedMessage
id='annual_report.summary.thanks'
defaultMessage='Thanks for being part of Mastodon!'
/>
</h1>
<p>
<FormattedMessage
id='annual_report.summary.here_it_is'
defaultMessage='Here is your {year} in review:'
values={{ year: report.year }}
/>
</p>
</div>
<div className='annual-report__bento annual-report__summary'>
<Archetype data={report.data.archetype} />
<HighlightedPost data={report.data.top_statuses} />
<Followers
data={report.data.time_series}
total={currentAccount?.followers_count}
/>
<MostUsedHashtag data={report.data.top_hashtags} />
<Percentile data={report.data.percentiles} />
<NewPosts data={report.data.time_series} />
</div>
</div>
);
};

View file

@ -0,0 +1,29 @@
import { FormattedMessage } from 'react-intl';
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
export const MostUsedApp: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const app = data[0];
if (!app) {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-app' />
);
}
return (
<div className='annual-report__bento__box annual-report__summary__most-used-app'>
<div className='annual-report__summary__most-used-app__icon'>
{app.name}
</div>
<div className='annual-report__summary__most-used-app__label'>
<FormattedMessage
id='annual_report.summary.most_used_app.most_used_app'
defaultMessage='most used app'
/>
</div>
</div>
);
};

View file

@ -0,0 +1,29 @@
import { FormattedMessage } from 'react-intl';
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
export const MostUsedHashtag: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const hashtag = data[0];
if (!hashtag) {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag' />
);
}
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
<div className='annual-report__summary__most-used-hashtag__hashtag'>
#{hashtag.name}
</div>
<div className='annual-report__summary__most-used-hashtag__label'>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
defaultMessage='most used hashtag'
/>
</div>
</div>
);
};

View file

@ -0,0 +1,53 @@
import { FormattedNumber, FormattedMessage } from 'react-intl';
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
export const NewPosts: React.FC<{
data: TimeSeriesMonth[];
}> = ({ data }) => {
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
return (
<div className='annual-report__bento__box annual-report__summary__new-posts'>
<svg width={500} height={500}>
<defs>
<pattern
id='posts'
x='0'
y='0'
width='32'
height='35'
patternUnits='userSpaceOnUse'
>
<circle cx='12' cy='12' r='12' fill='var(--lime)' />
<ChatBubbleIcon
fill='var(--indigo-1)'
x='4'
y='4'
width='16'
height='16'
/>
</pattern>
</defs>
<rect
width={500}
height={500}
fill='url(#posts)'
style={{ opacity: 0.2 }}
/>
</svg>
<div className='annual-report__summary__new-posts__number'>
<FormattedNumber value={posts} />
</div>
<div className='annual-report__summary__new-posts__label'>
<FormattedMessage
id='annual_report.summary.new_posts.new_posts'
defaultMessage='new posts'
/>
</div>
</div>
);
};

View file

@ -0,0 +1,53 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { FormattedMessage, FormattedNumber } from 'react-intl';
import type { Percentiles } from 'flavours/glitch/models/annual_report';
export const Percentile: React.FC<{
data: Percentiles;
}> = ({ data }) => {
const percentile = data.statuses;
return (
<div className='annual-report__bento__box annual-report__summary__percentile'>
<FormattedMessage
id='annual_report.summary.percentile.text'
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>'
values={{
topLabel: (str) => (
<div className='annual-report__summary__percentile__label'>
{str}
</div>
),
percentage: () => (
<div className='annual-report__summary__percentile__number'>
<FormattedNumber
value={percentile / 100}
style='percent'
maximumFractionDigits={1}
/>
</div>
),
bottomLabel: (str) => (
<div>
<div className='annual-report__summary__percentile__label'>
{str}
</div>
{percentile < 6 && (
<div className='annual-report__summary__percentile__footnote'>
<FormattedMessage
id='annual_report.summary.percentile.we_wont_tell_bernie'
defaultMessage="We won't tell Bernie."
/>
</div>
)}
</div>
),
}}
>
{(message) => <>{message}</>}
</FormattedMessage>
</div>
);
};

View file

@ -0,0 +1,59 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import CelebrationIcon from '@/material-icons/400-24px/celebration.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import { Icon } from 'flavours/glitch/components/icon';
import type { NotificationGroupAnnualReport } from 'flavours/glitch/models/notification_group';
import { useAppDispatch } from 'flavours/glitch/store';
export const NotificationAnnualReport: React.FC<{
notification: NotificationGroupAnnualReport;
unread: boolean;
}> = ({ notification: { annualReport }, unread }) => {
const dispatch = useAppDispatch();
const year = annualReport.year;
const handleClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ANNUAL_REPORT',
modalProps: { year },
}),
);
}, [dispatch, year]);
return (
<div
role='button'
className={classNames(
'notification-group notification-group--link notification-group--annual-report focusable',
{ 'notification-group--unread': unread },
)}
tabIndex={0}
>
<div className='notification-group__icon'>
<Icon id='celebration' icon={CelebrationIcon} />
</div>
<div className='notification-group__main'>
<p>
<FormattedMessage
id='notification.annual_report.message'
defaultMessage="Your {year} #Wrapstodon awaits! Unveil your year's highlights and memorable moments on Mastodon!"
values={{ year }}
/>
</p>
<button onClick={handleClick} className='link-button'>
<FormattedMessage
id='notification.annual_report.view'
defaultMessage='View #Wrapstodon'
/>
</button>
</div>
</div>
);
};

View file

@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NotificationAdminReport } from './notification_admin_report'; import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up'; import { NotificationAdminSignUp } from './notification_admin_sign_up';
import { NotificationAnnualReport } from './notification_annual_report';
import { NotificationFavourite } from './notification_favourite'; import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow'; import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request'; import { NotificationFollowRequest } from './notification_follow_request';
@ -143,6 +144,14 @@ export const NotificationGroup: React.FC<{
/> />
); );
break; break;
case 'annual_report':
content = (
<NotificationAnnualReport
unread={unread}
notification={notificationGroup}
/>
);
break;
default: default:
return null; return null;
} }

View file

@ -51,6 +51,7 @@ export const DetailedStatus: React.FC<{
domain: string; domain: string;
showMedia?: boolean; showMedia?: boolean;
withLogo?: boolean; withLogo?: boolean;
overrideDisplayName?: React.ReactNode;
pictureInPicture: any; pictureInPicture: any;
onToggleHidden?: (status: any) => void; onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void; onToggleMediaVisibility?: () => void;
@ -65,6 +66,7 @@ export const DetailedStatus: React.FC<{
domain, domain,
showMedia, showMedia,
withLogo, withLogo,
overrideDisplayName,
pictureInPicture, pictureInPicture,
onToggleMediaVisibility, onToggleMediaVisibility,
onToggleHidden, onToggleHidden,
@ -378,7 +380,11 @@ export const DetailedStatus: React.FC<{
<div className='detailed-status__display-avatar'> <div className='detailed-status__display-avatar'>
<Avatar account={status.get('account')} size={46} /> <Avatar account={status.get('account')} size={46} />
</div> </div>
<DisplayName account={status.get('account')} localDomain={domain} />
{overrideDisplayName ?? (
<DisplayName account={status.get('account')} localDomain={domain} />
)}
{withLogo && ( {withLogo && (
<> <>
<div className='spacer' /> <div className='spacer' />

View file

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

View file

@ -20,6 +20,7 @@ import {
SubscribedLanguagesModal, SubscribedLanguagesModal,
ClosedRegistrationsModal, ClosedRegistrationsModal,
IgnoreNotificationsModal, IgnoreNotificationsModal,
AnnualReportModal,
} from 'flavours/glitch/features/ui/util/async-components'; } from 'flavours/glitch/features/ui/util/async-components';
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar'; import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
@ -82,6 +83,7 @@ export const MODAL_COMPONENTS = {
'INTERACTION': InteractionModal, 'INTERACTION': InteractionModal,
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
'ANNUAL_REPORT': AnnualReportModal,
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View file

@ -229,3 +229,7 @@ export function NotificationRequest () {
export function LinkTimeline () { export function LinkTimeline () {
return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline'); return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline');
} }
export function AnnualReportModal () {
return import(/*webpackChunkName: "flavours/glitch/async/modals/annual_report_modal" */'../components/annual_report_modal');
}

View file

@ -0,0 +1,44 @@
export interface Percentiles {
followers: number;
statuses: number;
}
export interface NameAndCount {
name: string;
count: number;
}
export interface TimeSeriesMonth {
month: number;
statuses: number;
following: number;
followers: number;
}
export interface TopStatuses {
by_reblogs: number;
by_favourites: number;
by_replies: number;
}
export type Archetype =
| 'lurker'
| 'booster'
| 'pollster'
| 'replier'
| 'oracle';
interface AnnualReportV1 {
most_used_apps: NameAndCount[];
percentiles: Percentiles;
top_hashtags: NameAndCount[];
top_statuses: TopStatuses;
time_series: TimeSeriesMonth[];
archetype: Archetype;
}
export interface AnnualReport {
year: number;
schema_version: number;
data: AnnualReportV1;
}

View file

@ -1,6 +1,7 @@
import type { import type {
ApiAccountRelationshipSeveranceEventJSON, ApiAccountRelationshipSeveranceEventJSON,
ApiAccountWarningJSON, ApiAccountWarningJSON,
ApiAnnualReportEventJSON,
BaseNotificationGroupJSON, BaseNotificationGroupJSON,
ApiNotificationGroupJSON, ApiNotificationGroupJSON,
ApiNotificationJSON, ApiNotificationJSON,
@ -65,6 +66,12 @@ export interface NotificationGroupSeveredRelationships
event: AccountRelationshipSeveranceEvent; event: AccountRelationshipSeveranceEvent;
} }
type AnnualReportEvent = ApiAnnualReportEventJSON;
export interface NotificationGroupAnnualReport
extends BaseNotification<'annual_report'> {
annualReport: AnnualReportEvent;
}
interface Report extends Omit<ApiReportJSON, 'target_account'> { interface Report extends Omit<ApiReportJSON, 'target_account'> {
targetAccountId: string; targetAccountId: string;
} }
@ -86,7 +93,8 @@ export type NotificationGroup =
| NotificationGroupModerationWarning | NotificationGroupModerationWarning
| NotificationGroupSeveredRelationships | NotificationGroupSeveredRelationships
| NotificationGroupAdminSignUp | NotificationGroupAdminSignUp
| NotificationGroupAdminReport; | NotificationGroupAdminReport
| NotificationGroupAnnualReport;
function createReportFromJSON(reportJSON: ApiReportJSON): Report { function createReportFromJSON(reportJSON: ApiReportJSON): Report {
const { target_account, ...report } = reportJSON; const { target_account, ...report } = reportJSON;
@ -112,6 +120,12 @@ function createAccountRelationshipSeveranceEventFromJSON(
return eventJson; return eventJson;
} }
function createAnnualReportEventFromJSON(
eventJson: ApiAnnualReportEventJSON,
): AnnualReportEvent {
return eventJson;
}
export function createNotificationGroupFromJSON( export function createNotificationGroupFromJSON(
groupJson: ApiNotificationGroupJSON, groupJson: ApiNotificationGroupJSON,
): NotificationGroup { ): NotificationGroup {
@ -145,7 +159,6 @@ export function createNotificationGroupFromJSON(
event: createAccountRelationshipSeveranceEventFromJSON(group.event), event: createAccountRelationshipSeveranceEventFromJSON(group.event),
sampleAccountIds, sampleAccountIds,
}; };
case 'moderation_warning': { case 'moderation_warning': {
const { moderation_warning, ...groupWithoutModerationWarning } = group; const { moderation_warning, ...groupWithoutModerationWarning } = group;
return { return {
@ -154,6 +167,14 @@ export function createNotificationGroupFromJSON(
sampleAccountIds, sampleAccountIds,
}; };
} }
case 'annual_report': {
const { annual_report, ...groupWithoutAnnualReport } = group;
return {
...groupWithoutAnnualReport,
annualReport: createAnnualReportEventFromJSON(annual_report),
sampleAccountIds,
};
}
default: default:
return { return {
sampleAccountIds, sampleAccountIds,

View file

@ -0,0 +1,335 @@
: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);
}
}
}
&__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(--indigo-3) !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: 61px;
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: 480px;
background: var(--indigo-1);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
.loading-indicator .circular-progress {
color: var(--lime);
}
@media screen and (max-width: $no-columns-breakpoint) {
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
}
.notification-group--annual-report {
.notification-group__icon {
color: var(--lime);
}
.notification-group__main .link-button {
font-weight: 500;
color: var(--lime);
}
}

View file

@ -15,6 +15,7 @@
@import 'polls'; @import 'polls';
@import 'modal'; @import 'modal';
@import 'emoji_picker'; @import 'emoji_picker';
@import 'annual_reports';
@import 'about'; @import 'about';
@import 'tables'; @import 'tables';
@import 'admin'; @import 'admin';

View file

@ -1856,7 +1856,8 @@ body > [data-popper-placement] {
.status__wrapper-direct, .status__wrapper-direct,
.notification-ungrouped--direct, .notification-ungrouped--direct,
.notification-group--direct { .notification-group--direct,
.notification-group--annual-report {
background: rgba($ui-highlight-color, 0.05); background: rgba($ui-highlight-color, 0.05);
&:focus { &:focus {
@ -6241,7 +6242,8 @@ a.status-card {
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
background: rgba($base-overlay-background, 0.7); opacity: 0.9;
background: $base-overlay-background;
transition: background 0.5s; transition: background 0.5s;
} }