diff --git a/app/javascript/flavours/glitch/api_types/notifications.ts b/app/javascript/flavours/glitch/api_types/notifications.ts
index 17c2ede32b..d173083dbd 100644
--- a/app/javascript/flavours/glitch/api_types/notifications.ts
+++ b/app/javascript/flavours/glitch/api_types/notifications.ts
@@ -20,6 +20,7 @@ export const allNotificationTypes = [
'admin.report',
'moderation_warning',
'severed_relationships',
+ 'annual_report',
];
export type NotificationWithStatusType =
@@ -37,7 +38,8 @@ export type NotificationType =
| 'moderation_warning'
| 'severed_relationships'
| 'admin.sign_up'
- | 'admin.report';
+ | 'admin.report'
+ | 'annual_report';
export interface BaseNotificationJSON {
id: string;
@@ -130,6 +132,15 @@ interface AccountRelationshipSeveranceNotificationJSON
event: ApiAccountRelationshipSeveranceEventJSON;
}
+export interface ApiAnnualReportEventJSON {
+ year: string;
+}
+
+interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON {
+ type: 'annual_report';
+ annual_report: ApiAnnualReportEventJSON;
+}
+
export type ApiNotificationJSON =
| SimpleNotificationJSON
| ReportNotificationJSON
@@ -142,7 +153,8 @@ export type ApiNotificationGroupJSON =
| ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON
- | ModerationWarningNotificationGroupJSON;
+ | ModerationWarningNotificationGroupJSON
+ | AnnualReportNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[];
diff --git a/app/javascript/flavours/glitch/components/modal_root.jsx b/app/javascript/flavours/glitch/components/modal_root.jsx
index 71b875cfee..bfe9efea06 100644
--- a/app/javascript/flavours/glitch/components/modal_root.jsx
+++ b/app/javascript/flavours/glitch/components/modal_root.jsx
@@ -13,11 +13,14 @@ class ModalRoot extends PureComponent {
static propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
- backgroundColor: PropTypes.shape({
- r: PropTypes.number,
- g: PropTypes.number,
- b: PropTypes.number,
- }),
+ backgroundColor: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.shape({
+ r: PropTypes.number,
+ g: PropTypes.number,
+ b: PropTypes.number,
+ }),
+ ]),
noEsc: PropTypes.bool,
ignoreFocus: PropTypes.bool,
...WithOptionalRouterPropTypes,
@@ -146,14 +149,17 @@ class ModalRoot extends PureComponent {
let backgroundColor = null;
- if (this.props.backgroundColor) {
- backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
+ if (this.props.backgroundColor && typeof this.props.backgroundColor === 'string') {
+ 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 (
diff --git a/app/javascript/flavours/glitch/features/annual_report/archetype.tsx b/app/javascript/flavours/glitch/features/annual_report/archetype.tsx
new file mode 100644
index 0000000000..604f401039
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/archetype.tsx
@@ -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 = (
+
+ );
+ break;
+ case 'replier':
+ illustration = replier;
+ label = (
+
+ );
+ break;
+ case 'pollster':
+ illustration = pollster;
+ label = (
+
+ );
+ break;
+ case 'lurker':
+ illustration = lurker;
+ label = (
+
+ );
+ break;
+ case 'oracle':
+ illustration = oracle;
+ label = (
+
+ );
+ break;
+ }
+
+ return (
+
+
{label}
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/followers.tsx b/app/javascript/flavours/glitch/features/annual_report/followers.tsx
new file mode 100644
index 0000000000..e5238705d7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/followers.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {change > -1 ? '+' : '-'}
+
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx b/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx
new file mode 100644
index 0000000000..e66752b53e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/highlighted_post.tsx
@@ -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 = (
+
+ );
+ } else if (data.by_favourites) {
+ statusId = data.by_favourites;
+ label = (
+
+ );
+ } else {
+ statusId = data.by_replies;
+ label = (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ const displayName = (
+
+
+
+ ),
+ }}
+ />
+
+ {label}
+
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/index.tsx b/app/javascript/flavours/glitch/features/annual_report/index.tsx
new file mode 100644
index 0000000000..0c737934b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/index.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const currentAccount = useAppSelector((state) =>
+ me ? state.accounts.get(me) : undefined,
+ );
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ setLoading(true);
+
+ apiRequestGet(`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 ;
+ }
+
+ const report = response?.annual_reports[0];
+
+ if (!report) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/most_used_app.tsx b/app/javascript/flavours/glitch/features/annual_report/most_used_app.tsx
new file mode 100644
index 0000000000..f78a02b296
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/most_used_app.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/most_used_hashtag.tsx b/app/javascript/flavours/glitch/features/annual_report/most_used_hashtag.tsx
new file mode 100644
index 0000000000..a6d5e08fe1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/most_used_hashtag.tsx
@@ -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 (
+
+ );
+ }
+
+ return (
+
+
+ #{hashtag.name}
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/new_posts.tsx b/app/javascript/flavours/glitch/features/annual_report/new_posts.tsx
new file mode 100644
index 0000000000..4ca286debb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/new_posts.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/annual_report/percentile.tsx b/app/javascript/flavours/glitch/features/annual_report/percentile.tsx
new file mode 100644
index 0000000000..322171ba21
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/annual_report/percentile.tsx
@@ -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 (
+
+
(
+
+ {str}
+
+ ),
+ percentage: () => (
+
+
+
+ ),
+ bottomLabel: (str) => (
+
+
+ {str}
+
+
+ {percentile < 6 && (
+
+
+
+ )}
+
+ ),
+ }}
+ >
+ {(message) => <>{message}>}
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_annual_report.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_annual_report.tsx
new file mode 100644
index 0000000000..42754d1490
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_annual_report.tsx
@@ -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 (
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx
index f4275179c5..9bd6c27a86 100644
--- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx
+++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx
@@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up';
+import { NotificationAnnualReport } from './notification_annual_report';
import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request';
@@ -143,6 +144,14 @@ export const NotificationGroup: React.FC<{
/>
);
break;
+ case 'annual_report':
+ content = (
+
+ );
+ break;
default:
return null;
}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
index 0b08e88cf8..c6f0900c5f 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx
@@ -51,6 +51,7 @@ export const DetailedStatus: React.FC<{
domain: string;
showMedia?: boolean;
withLogo?: boolean;
+ overrideDisplayName?: React.ReactNode;
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
@@ -65,6 +66,7 @@ export const DetailedStatus: React.FC<{
domain,
showMedia,
withLogo,
+ overrideDisplayName,
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
@@ -378,7 +380,11 @@ export const DetailedStatus: React.FC<{
-
+
+ {overrideDisplayName ?? (
+
+ )}
+
{withLogo && (
<>
diff --git a/app/javascript/flavours/glitch/features/ui/components/annual_report_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/annual_report_modal.tsx
new file mode 100644
index 0000000000..b7f5db04ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/annual_report_modal.tsx
@@ -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 (
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default AnnualReportModal;
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
index 83345905be..506cccfbb2 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
@@ -20,6 +20,7 @@ import {
SubscribedLanguagesModal,
ClosedRegistrationsModal,
IgnoreNotificationsModal,
+ AnnualReportModal,
} from 'flavours/glitch/features/ui/util/async-components';
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
@@ -82,6 +83,7 @@ export const MODAL_COMPONENTS = {
'INTERACTION': InteractionModal,
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
+ 'ANNUAL_REPORT': AnnualReportModal,
};
export default class ModalRoot extends PureComponent {
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
index c7f2e6cff9..1354c21f09 100644
--- a/app/javascript/flavours/glitch/features/ui/util/async-components.js
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -229,3 +229,7 @@ export function NotificationRequest () {
export function LinkTimeline () {
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');
+}
diff --git a/app/javascript/flavours/glitch/models/annual_report.ts b/app/javascript/flavours/glitch/models/annual_report.ts
new file mode 100644
index 0000000000..c0a101e6c8
--- /dev/null
+++ b/app/javascript/flavours/glitch/models/annual_report.ts
@@ -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;
+}
diff --git a/app/javascript/flavours/glitch/models/notification_group.ts b/app/javascript/flavours/glitch/models/notification_group.ts
index 26a4d6be84..ff963307c6 100644
--- a/app/javascript/flavours/glitch/models/notification_group.ts
+++ b/app/javascript/flavours/glitch/models/notification_group.ts
@@ -1,6 +1,7 @@
import type {
ApiAccountRelationshipSeveranceEventJSON,
ApiAccountWarningJSON,
+ ApiAnnualReportEventJSON,
BaseNotificationGroupJSON,
ApiNotificationGroupJSON,
ApiNotificationJSON,
@@ -65,6 +66,12 @@ export interface NotificationGroupSeveredRelationships
event: AccountRelationshipSeveranceEvent;
}
+type AnnualReportEvent = ApiAnnualReportEventJSON;
+export interface NotificationGroupAnnualReport
+ extends BaseNotification<'annual_report'> {
+ annualReport: AnnualReportEvent;
+}
+
interface Report extends Omit {
targetAccountId: string;
}
@@ -86,7 +93,8 @@ export type NotificationGroup =
| NotificationGroupModerationWarning
| NotificationGroupSeveredRelationships
| NotificationGroupAdminSignUp
- | NotificationGroupAdminReport;
+ | NotificationGroupAdminReport
+ | NotificationGroupAnnualReport;
function createReportFromJSON(reportJSON: ApiReportJSON): Report {
const { target_account, ...report } = reportJSON;
@@ -112,6 +120,12 @@ function createAccountRelationshipSeveranceEventFromJSON(
return eventJson;
}
+function createAnnualReportEventFromJSON(
+ eventJson: ApiAnnualReportEventJSON,
+): AnnualReportEvent {
+ return eventJson;
+}
+
export function createNotificationGroupFromJSON(
groupJson: ApiNotificationGroupJSON,
): NotificationGroup {
@@ -145,7 +159,6 @@ export function createNotificationGroupFromJSON(
event: createAccountRelationshipSeveranceEventFromJSON(group.event),
sampleAccountIds,
};
-
case 'moderation_warning': {
const { moderation_warning, ...groupWithoutModerationWarning } = group;
return {
@@ -154,6 +167,14 @@ export function createNotificationGroupFromJSON(
sampleAccountIds,
};
}
+ case 'annual_report': {
+ const { annual_report, ...groupWithoutAnnualReport } = group;
+ return {
+ ...groupWithoutAnnualReport,
+ annualReport: createAnnualReportEventFromJSON(annual_report),
+ sampleAccountIds,
+ };
+ }
default:
return {
sampleAccountIds,
diff --git a/app/javascript/flavours/glitch/styles/annual_reports.scss b/app/javascript/flavours/glitch/styles/annual_reports.scss
new file mode 100644
index 0000000000..39784e3b5e
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/annual_reports.scss
@@ -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);
+ }
+}
diff --git a/app/javascript/flavours/glitch/styles/application.scss b/app/javascript/flavours/glitch/styles/application.scss
index fd55960311..ac58cca66a 100644
--- a/app/javascript/flavours/glitch/styles/application.scss
+++ b/app/javascript/flavours/glitch/styles/application.scss
@@ -15,6 +15,7 @@
@import 'polls';
@import 'modal';
@import 'emoji_picker';
+@import 'annual_reports';
@import 'about';
@import 'tables';
@import 'admin';
diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss
index 5048798a0c..f35f5702b4 100644
--- a/app/javascript/flavours/glitch/styles/components.scss
+++ b/app/javascript/flavours/glitch/styles/components.scss
@@ -1856,7 +1856,8 @@ body > [data-popper-placement] {
.status__wrapper-direct,
.notification-ungrouped--direct,
-.notification-group--direct {
+.notification-group--direct,
+.notification-group--annual-report {
background: rgba($ui-highlight-color, 0.05);
&:focus {
@@ -6241,7 +6242,8 @@ a.status-card {
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
- background: rgba($base-overlay-background, 0.7);
+ opacity: 0.9;
+ background: $base-overlay-background;
transition: background 0.5s;
}