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({ | ||||
|       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 ( | ||||
|       <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> | ||||
|           <DisplayName account={status.get('account')} localDomain={domain} /> | ||||
| 
 | ||||
|           {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 | ||||
|  | ||||