diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb
index 9bc8e68ac2..b1aee288dd 100644
--- a/app/controllers/api/v1/annual_reports_controller.rb
+++ b/app/controllers/api/v1/annual_reports_controller.rb
@@ -17,6 +17,17 @@ class Api::V1::AnnualReportsController < Api::BaseController
relationships: @relationships
end
+ def show
+ with_read_replica do
+ @presenter = AnnualReportsPresenter.new([@annual_report])
+ @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
+ end
+
+ render json: @presenter,
+ serializer: REST::AnnualReportsSerializer,
+ relationships: @relationships
+ end
+
def read
@annual_report.view!
render_empty
diff --git a/app/javascript/images/archetypes/booster.png b/app/javascript/images/archetypes/booster.png
new file mode 100755
index 0000000000..18c92dfb7d
Binary files /dev/null and b/app/javascript/images/archetypes/booster.png differ
diff --git a/app/javascript/images/archetypes/lurker.png b/app/javascript/images/archetypes/lurker.png
new file mode 100755
index 0000000000..8e1d6451b0
Binary files /dev/null and b/app/javascript/images/archetypes/lurker.png differ
diff --git a/app/javascript/images/archetypes/oracle.png b/app/javascript/images/archetypes/oracle.png
new file mode 100755
index 0000000000..2afd3c72e1
Binary files /dev/null and b/app/javascript/images/archetypes/oracle.png differ
diff --git a/app/javascript/images/archetypes/pollster.png b/app/javascript/images/archetypes/pollster.png
new file mode 100755
index 0000000000..b838fccdd6
Binary files /dev/null and b/app/javascript/images/archetypes/pollster.png differ
diff --git a/app/javascript/images/archetypes/replier.png b/app/javascript/images/archetypes/replier.png
new file mode 100755
index 0000000000..b298d4221c
Binary files /dev/null and b/app/javascript/images/archetypes/replier.png differ
diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts
index 28ba7eb5c2..190d8c8396 100644
--- a/app/javascript/mastodon/api_types/notifications.ts
+++ b/app/javascript/mastodon/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/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx
index e7fa5e6f9a..b0d88fe8f9 100644
--- a/app/javascript/mastodon/components/modal_root.jsx
+++ b/app/javascript/mastodon/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,
+ }),
+ ]),
ignoreFocus: PropTypes.bool,
...WithOptionalRouterPropTypes,
};
@@ -141,14 +144,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/mastodon/features/annual_report/archetype.tsx b/app/javascript/mastodon/features/annual_report/archetype.tsx
new file mode 100644
index 0000000000..fffbc1803e
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/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/mastodon/features/annual_report/followers.tsx b/app/javascript/mastodon/features/annual_report/followers.tsx
new file mode 100644
index 0000000000..196013ae9d
--- /dev/null
+++ b/app/javascript/mastodon/features/annual_report/followers.tsx
@@ -0,0 +1,69 @@
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+
+import { ShortNumber } from 'mastodon/components/short_number';
+import type { TimeSeriesMonth } from 'mastodon/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/mastodon/features/annual_report/highlighted_post.tsx b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx
new file mode 100644
index 0000000000..3a2a70713d
--- /dev/null
+++ b/app/javascript/mastodon/features/annual_report/highlighted_post.tsx
@@ -0,0 +1,105 @@
+/* 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 'mastodon/actions/statuses';
+import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
+import { me } from 'mastodon/initial_state';
+import type { TopStatuses } from 'mastodon/models/annual_report';
+import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
+import { useAppSelector, useAppDispatch } from 'mastodon/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/mastodon/features/annual_report/index.tsx b/app/javascript/mastodon/features/annual_report/index.tsx
new file mode 100644
index 0000000000..91f03330c0
--- /dev/null
+++ b/app/javascript/mastodon/features/annual_report/index.tsx
@@ -0,0 +1,99 @@
+import { useState, useEffect } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import {
+ importFetchedStatuses,
+ importFetchedAccounts,
+} from 'mastodon/actions/importer';
+import { apiRequestGet, apiRequestPost } from 'mastodon/api';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { me } from 'mastodon/initial_state';
+import type { Account } from 'mastodon/models/account';
+import type { AnnualReport as AnnualReportData } from 'mastodon/models/annual_report';
+import type { Status } from 'mastodon/models/status';
+import { useAppSelector, useAppDispatch } from 'mastodon/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/mastodon/features/annual_report/most_used_app.tsx b/app/javascript/mastodon/features/annual_report/most_used_app.tsx
new file mode 100644
index 0000000000..2d8c8aa582
--- /dev/null
+++ b/app/javascript/mastodon/features/annual_report/most_used_app.tsx
@@ -0,0 +1,29 @@
+import { FormattedMessage } from 'react-intl';
+
+import type { NameAndCount } from 'mastodon/models/annual_report';
+
+export const MostUsedApp: React.FC<{
+ data: NameAndCount[];
+}> = ({ data }) => {
+ const app = data[0];
+
+ if (!app) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx
new file mode 100644
index 0000000000..0e4c78f639
--- /dev/null
+++ b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx
@@ -0,0 +1,29 @@
+import { FormattedMessage } from 'react-intl';
+
+import type { NameAndCount } from 'mastodon/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/mastodon/features/annual_report/new_posts.tsx b/app/javascript/mastodon/features/annual_report/new_posts.tsx
new file mode 100644
index 0000000000..9ead0176b2
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/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/mastodon/features/annual_report/percentile.tsx b/app/javascript/mastodon/features/annual_report/percentile.tsx
new file mode 100644
index 0000000000..a758db72c1
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/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/mastodon/features/notifications_v2/components/notification_annual_report.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_annual_report.tsx
new file mode 100644
index 0000000000..8b92f31add
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/actions/modal';
+import { Icon } from 'mastodon/components/icon';
+import type { NotificationGroupAnnualReport } from 'mastodon/models/notification_group';
+import { useAppDispatch } from 'mastodon/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/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx
index 36f033261a..d5eb851985 100644
--- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx
+++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx
@@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/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/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx
index 5811efb190..deb330b9a0 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.tsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx
@@ -49,6 +49,7 @@ export const DetailedStatus: React.FC<{
domain: string;
showMedia?: boolean;
withLogo?: boolean;
+ overrideDisplayName?: React.ReactNode;
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
@@ -62,6 +63,7 @@ export const DetailedStatus: React.FC<{
domain,
showMedia,
withLogo,
+ overrideDisplayName,
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
@@ -319,7 +321,11 @@ export const DetailedStatus: React.FC<{
-
+
+ {overrideDisplayName ?? (
+
+ )}
+
{withLogo && (
<>
diff --git a/app/javascript/mastodon/features/ui/components/annual_report_modal.tsx b/app/javascript/mastodon/features/ui/components/annual_report_modal.tsx
new file mode 100644
index 0000000000..8c39c0b3aa
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/annual_report_modal.tsx
@@ -0,0 +1,21 @@
+import { useEffect } from 'react';
+
+import { AnnualReport } from 'mastodon/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/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index 64933fd1ae..42a00acf81 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -18,6 +18,7 @@ import {
SubscribedLanguagesModal,
ClosedRegistrationsModal,
IgnoreNotificationsModal,
+ AnnualReportModal,
} from 'mastodon/features/ui/util/async-components';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
@@ -72,6 +73,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/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 7e9a7af00a..ff5db65347 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -217,3 +217,7 @@ export function NotificationRequest () {
export function LinkTimeline () {
return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline');
}
+
+export function AnnualReportModal () {
+ return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal');
+}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index e91d6e52fc..5bc8c4ad71 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -87,6 +87,24 @@
"alert.unexpected.title": "Oops!",
"alt_text_badge.title": "Alt text",
"announcement.announcement": "Announcement",
+ "annual_report.summary.archetype.booster": "The cool-hunter",
+ "annual_report.summary.archetype.lurker": "The lurker",
+ "annual_report.summary.archetype.oracle": "The oracle",
+ "annual_report.summary.archetype.pollster": "The pollster",
+ "annual_report.summary.archetype.replier": "The social butterfly",
+ "annual_report.summary.followers.followers": "followers",
+ "annual_report.summary.followers.total": "{count} total",
+ "annual_report.summary.here_it_is": "Here is your {year} in review:",
+ "annual_report.summary.highlighted_post.by_favourites": "most favourited post",
+ "annual_report.summary.highlighted_post.by_reblogs": "most boosted post",
+ "annual_report.summary.highlighted_post.by_replies": "post with the most replies",
+ "annual_report.summary.highlighted_post.possessive": "{name}'s",
+ "annual_report.summary.most_used_app.most_used_app": "most used app",
+ "annual_report.summary.most_used_hashtag.most_used_hashtag": "most used hashtag",
+ "annual_report.summary.new_posts.new_posts": "new posts",
+ "annual_report.summary.percentile.text": "That puts you in the topof Mastodon users.",
+ "annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
+ "annual_report.summary.thanks": "Thanks for being part of Mastodon!",
"attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio",
"block_modal.remote_users_caveat": "We will ask the server {domain} to respect your decision. However, compliance is not guaranteed since some servers may handle blocks differently. Public posts may still be visible to non-logged-in users.",
@@ -508,6 +526,8 @@
"notification.admin.report_statuses_other": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.admin.sign_up.name_and_others": "{name} and {count, plural, one {# other} other {# others}} signed up",
+ "notification.annual_report.message": "Your {year} #Wrapstodon awaits! Unveil your year's highlights and memorable moments on Mastodon!",
+ "notification.annual_report.view": "View #Wrapstodon",
"notification.favourite": "{name} favorited your post",
"notification.favourite.name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}} favorited your post",
"notification.follow": "{name} followed you",
diff --git a/app/javascript/mastodon/models/annual_report.ts b/app/javascript/mastodon/models/annual_report.ts
new file mode 100644
index 0000000000..c0a101e6c8
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts
index 09d407d449..01a341e8dd 100644
--- a/app/javascript/mastodon/models/notification_group.ts
+++ b/app/javascript/mastodon/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/material-icons/400-24px/celebration-fill.svg b/app/javascript/material-icons/400-24px/celebration-fill.svg
new file mode 100644
index 0000000000..d715cf2fb3
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/celebration-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/celebration.svg b/app/javascript/material-icons/400-24px/celebration.svg
new file mode 100644
index 0000000000..1d1b19ee72
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/celebration.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/explore-fill.svg b/app/javascript/material-icons/400-24px/explore-fill.svg
index febe0a63b4..919ecb580a 100644
--- a/app/javascript/material-icons/400-24px/explore-fill.svg
+++ b/app/javascript/material-icons/400-24px/explore-fill.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/explore.svg b/app/javascript/material-icons/400-24px/explore.svg
index 547a999421..bb8ba1f4c6 100644
--- a/app/javascript/material-icons/400-24px/explore.svg
+++ b/app/javascript/material-icons/400-24px/explore.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
index 465b748078..109b69bca5 100644
--- a/app/javascript/styles/application.scss
+++ b/app/javascript/styles/application.scss
@@ -15,6 +15,7 @@
@import 'mastodon/polls';
@import 'mastodon/modal';
@import 'mastodon/emoji_picker';
+@import 'mastodon/annual_reports';
@import 'mastodon/about';
@import 'mastodon/tables';
@import 'mastodon/admin';
diff --git a/app/javascript/styles/mastodon/annual_reports.scss b/app/javascript/styles/mastodon/annual_reports.scss
new file mode 100644
index 0000000000..39784e3b5e
--- /dev/null
+++ b/app/javascript/styles/mastodon/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/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f688b9b4ba..1bfe37cee6 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1686,7 +1686,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 {
@@ -5784,7 +5785,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;
}
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 695f39a316..7b90fd92f9 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -67,6 +67,9 @@ class Notification < ApplicationRecord
moderation_warning: {
filterable: false,
}.freeze,
+ annual_report: {
+ filterable: false,
+ }.freeze,
'admin.sign_up': {
filterable: false,
}.freeze,
@@ -101,6 +104,7 @@ class Notification < ApplicationRecord
belongs_to :report, inverse_of: false
belongs_to :account_relationship_severance_event, inverse_of: false
belongs_to :account_warning, inverse_of: false
+ belongs_to :generated_annual_report, inverse_of: false
end
validates :type, inclusion: { in: TYPES }
@@ -309,7 +313,7 @@ class Notification < ApplicationRecord
self.from_account_id = activity&.status&.account_id
when 'Account'
self.from_account_id = activity&.id
- when 'AccountRelationshipSeveranceEvent', 'AccountWarning'
+ when 'AccountRelationshipSeveranceEvent', 'AccountWarning', 'GeneratedAnnualReport'
# These do not really have an originating account, but this is mandatory
# in the data model, and the recipient's account will by definition
# always exist
diff --git a/app/models/notification_group.rb b/app/models/notification_group.rb
index b6aa4d309c..9331b9406f 100644
--- a/app/models/notification_group.rb
+++ b/app/models/notification_group.rb
@@ -51,6 +51,7 @@ class NotificationGroup < ActiveModelSerializers::Model
:report,
:account_relationship_severance_event,
:account_warning,
+ :generated_annual_report,
to: :notification, prefix: false
class << self
diff --git a/app/serializers/rest/annual_report_event_serializer.rb b/app/serializers/rest/annual_report_event_serializer.rb
new file mode 100644
index 0000000000..555a596357
--- /dev/null
+++ b/app/serializers/rest/annual_report_event_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::AnnualReportEventSerializer < ActiveModel::Serializer
+ attributes :year
+
+ def year
+ object.year.to_s
+ end
+end
diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb
index 7e8f00df3c..f4af842e38 100644
--- a/app/serializers/rest/notification_group_serializer.rb
+++ b/app/serializers/rest/notification_group_serializer.rb
@@ -13,6 +13,7 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
belongs_to :account_relationship_severance_event, key: :event, if: :relationship_severance_event?, serializer: REST::AccountRelationshipSeveranceEventSerializer
belongs_to :account_warning, key: :moderation_warning, if: :moderation_warning_event?, serializer: REST::AccountWarningSerializer
+ belongs_to :generated_annual_report, key: :annual_report, if: :annual_report_event?, serializer: REST::AnnualReportEventSerializer
def sample_account_ids
object.sample_accounts.pluck(:id).map(&:to_s)
@@ -38,6 +39,10 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer
object.type == :moderation_warning
end
+ def annual_report_event?
+ object.type == :annual_report
+ end
+
def page_min_id
object.pagination_data[:min_id].to_s
end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 9aebab787e..0cf56c5a24 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -3,7 +3,7 @@
class NotifyService < BaseService
include Redisable
- # TODO: the severed_relationships type probably warrants email notifications
+ # TODO: the severed_relationships and annual_report types probably warrants email notifications
NON_EMAIL_TYPES = %i(
admin.report
admin.sign_up
@@ -12,6 +12,7 @@ class NotifyService < BaseService
status
moderation_warning
severed_relationships
+ annual_report
).freeze
class BaseCondition
@@ -25,6 +26,7 @@ class NotifyService < BaseService
poll
update
account_warning
+ annual_report
).freeze
def initialize(notification)
@@ -100,7 +102,7 @@ class NotifyService < BaseService
class DropCondition < BaseCondition
def drop?
blocked = @recipient.unavailable?
- blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type)
+ blocked ||= from_self? && %i(poll severed_relationships moderation_warning annual_report).exclude?(@notification.type)
return blocked if message? && from_staff?
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 57ce3ba9fd..ccdd83dec7 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -52,7 +52,7 @@ namespace :api, format: false do
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
resources :preferences, only: [:index]
- resources :annual_reports, only: [:index] do
+ resources :annual_reports, only: [:index, :show] do
member do
post :read
end