Add year in review feature to web UI (#32709)
@ -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
|
||||
|
BIN
app/javascript/images/archetypes/booster.png
Executable file
After Width: | Height: | Size: 620 KiB |
BIN
app/javascript/images/archetypes/lurker.png
Executable file
After Width: | Height: | Size: 1.0 MiB |
BIN
app/javascript/images/archetypes/oracle.png
Executable file
After Width: | Height: | Size: 1.2 MiB |
BIN
app/javascript/images/archetypes/pollster.png
Executable file
After Width: | Height: | Size: 710 KiB |
BIN
app/javascript/images/archetypes/replier.png
Executable file
After Width: | Height: | Size: 786 KiB |
@ -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[];
|
||||
|
@ -13,11 +13,14 @@ class ModalRoot extends PureComponent {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
backgroundColor: PropTypes.shape({
|
||||
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 (
|
||||
<div className='modal-root' ref={this.setRef}>
|
||||
<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>
|
||||
</div>
|
||||
|
69
app/javascript/mastodon/features/annual_report/archetype.tsx
Normal 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 'mastodon/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>
|
||||
);
|
||||
};
|
69
app/javascript/mastodon/features/annual_report/followers.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
@ -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 = (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
99
app/javascript/mastodon/features/annual_report/index.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
53
app/javascript/mastodon/features/annual_report/new_posts.tsx
Normal 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 'mastodon/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>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
@ -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 = (
|
||||
<NotificationAnnualReport
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -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<{
|
||||
<div className='detailed-status__display-avatar'>
|
||||
<Avatar account={status.get('account')} size={46} />
|
||||
</div>
|
||||
|
||||
{overrideDisplayName ?? (
|
||||
<DisplayName account={status.get('account')} localDomain={domain} />
|
||||
)}
|
||||
|
||||
{withLogo && (
|
||||
<>
|
||||
<div className='spacer' />
|
||||
|
@ -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 (
|
||||
<div className='modal-root__modal annual-report-modal'>
|
||||
<AnnualReport year={year} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AnnualReportModal;
|
@ -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 {
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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": "<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>",
|
||||
"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 <a>{count, plural, one {# other} other {# others}}</a> favorited your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
|
44
app/javascript/mastodon/models/annual_report.ts
Normal 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;
|
||||
}
|
@ -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<ApiReportJSON, 'target_account'> {
|
||||
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,
|
||||
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m80-80 200-560 360 360L80-80Zm502-378-42-42 224-224q32-32 77-32t77 32l24 24-42 42-24-24q-14-14-35-14t-35 14L582-458ZM422-618l-42-42 24-24q14-14 14-34t-14-34l-26-26 42-42 26 26q32 32 32 76t-32 76l-24 24Zm80 80-42-42 144-144q14-14 14-35t-14-35l-64-64 42-42 64 64q32 32 32 77t-32 77L502-538Zm160 160-42-42 64-64q32-32 77-32t77 32l64 64-42 42-64-64q-14-14-35-14t-35 14l-64 64Z"/></svg>
|
After Width: | Height: | Size: 478 B |
1
app/javascript/material-icons/400-24px/celebration.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m80-80 200-560 360 360L80-80Zm132-132 282-100-182-182-100 282Zm370-246-42-42 224-224q32-32 77-32t77 32l24 24-42 42-24-24q-14-14-35-14t-35 14L582-458ZM422-618l-42-42 24-24q14-14 14-34t-14-34l-26-26 42-42 26 26q32 32 32 76t-32 76l-24 24Zm80 80-42-42 144-144q14-14 14-35t-14-35l-64-64 42-42 64 64q32 32 32 77t-32 77L502-538Zm160 160-42-42 64-64q32-32 77-32t77 32l64 64-42 42-64-64q-14-14-35-14t-35 14l-64 64ZM212-212Z"/></svg>
|
After Width: | Height: | Size: 520 B |
@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m260-260 300-140 140-300-300 140-140 300Zm220-180q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m300-300 280-80 80-280-280 80-80 280Zm180-120q-25 0-42.5-17.5T420-480q0-25 17.5-42.5T480-540q25 0 42.5 17.5T540-480q0 25-17.5 42.5T480-420Zm0 340q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>
|
Before Width: | Height: | Size: 437 B After Width: | Height: | Size: 433 B |
@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m260-260 300-140 140-300-300 140-140 300Zm220-180q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m300-300 280-80 80-280-280 80-80 280Zm180-120q-25 0-42.5-17.5T420-480q0-25 17.5-42.5T480-540q25 0 42.5 17.5T540-480q0 25-17.5 42.5T480-420Zm0 340q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q133 0 226.5-93.5T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160Zm0-320Z"/></svg>
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 547 B |
@ -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';
|
||||
|
335
app/javascript/styles/mastodon/annual_reports.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -51,6 +51,7 @@ class NotificationGroup < ActiveModelSerializers::Model
|
||||
:report,
|
||||
:account_relationship_severance_event,
|
||||
:account_warning,
|
||||
:generated_annual_report,
|
||||
to: :notification, prefix: false
|
||||
|
||||
class << self
|
||||
|
9
app/serializers/rest/annual_report_event_serializer.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::AnnualReportEventSerializer < ActiveModel::Serializer
|
||||
attributes :year
|
||||
|
||||
def year
|
||||
object.year.to_s
|
||||
end
|
||||
end
|
@ -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
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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
|
||||
|