Compare commits

...

15 Commits

Author SHA1 Message Date
Claire
8b418b84d0 Merge pull request #3312 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d6f2a3ac8d
2025-12-10 22:09:22 +01:00
diondiondion
f817300d8d [Glitch] Implement custom font for Wrapstodon heading
Port c42b9f6996 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:13:17 +01:00
Echo
35a89a0173 [Glitch] Fix issue where Wrapstodon was pushed to the bottom of the feed
Port 76184c998c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:12:23 +01:00
diondiondion
b5721dbd4a [Glitch] Fix Wrapstodon Storybook & other Wrapstodon issues
Port 8137ce87ce to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:11:08 +01:00
diondiondion
38f623eee7 [Glitch] Minor Wrapstodon tweaks, add stub Storybook page
Port 91500a7f53 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-10 18:09:09 +01:00
Claire
17ba99e5de Merge commit 'd6f2a3ac8d61e0828a17f68a6e9094d0f4662f4c' into glitch-soc/merge-upstream
Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict because of glitch-soc's theming change.
  Adapted upstream's changes.
- `docker-compose.yml`:
  Conflict because of container repo name change.
  Adapted upstream's changes.
- `yarn.lock`:
  Conflict because of an additional glitch-soc dependency.
  Updated the dependencies upstream did.
2025-12-10 18:05:44 +01:00
Claire
d6f2a3ac8d Bump version to v4.5.3 (#37166) 2025-12-10 16:42:19 +00:00
diondiondion
c42b9f6996 Implement custom font for Wrapstodon heading (#37193) 2025-12-10 16:26:46 +00:00
Echo
76184c998c Fix issue where Wrapstodon was pushed to the bottom of the feed (#37190) 2025-12-10 15:55:12 +00:00
diondiondion
8137ce87ce Fix Wrapstodon Storybook & other Wrapstodon issues (#37189) 2025-12-10 14:07:25 +00:00
renovate[bot]
37426288d9 Update dependency postcss-preset-env to v10.5.0 (#37132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:39 +00:00
renovate[bot]
801fee7593 Update dependency test-prof to v1.5.0 (#37127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 12:53:29 +00:00
Claire
6838497fe8 Add title and description to Opengraph data for Wrapstodon share page (#37188) 2025-12-10 11:27:10 +00:00
Claire
7b8a5d42f1 Remove unused time series details from 2025 annual report (#37187) 2025-12-10 11:02:24 +00:00
diondiondion
91500a7f53 Minor Wrapstodon tweaks, add stub Storybook page (#37186) 2025-12-10 09:05:14 +00:00
43 changed files with 839 additions and 220 deletions

View File

@@ -2,6 +2,26 @@
All notable changes to this project will be documented in this file.
## [4.5.3] - 2025-12-08
### Security
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
### Fixed
- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire)
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
- Fix creation of duplicate conversations (#37108 by @oneiros)
- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima)
- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire)
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion)
- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire)
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire)
## [4.5.2] - 2025-11-20
### Changed

View File

@@ -855,7 +855,7 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
climate_control
test-prof (1.4.4)
test-prof (1.5.0)
thor (1.4.0)
tilt (2.6.1)
timeout (0.4.3)

View File

@@ -1,5 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
import api, { getLinks } from 'flavours/glitch/api';
import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
@@ -7,7 +8,7 @@ import { toServerSideType } from 'flavours/glitch/utils/filters';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed';
import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed';
@@ -25,9 +26,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
TIMELINE_WRAPSTODON,
];
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
timeline,
@@ -135,6 +143,7 @@ export function expandTimeline(timelineId, path, params = {}) {
if (timelineId === 'home') {
dispatch(submitMarkers());
dispatch(reinsertAnnualReport())
}
} catch(error) {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
export function isNonStatusId(value: unknown) {
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
}
export const disconnectTimeline = createAction(
'timeline/disconnect',
({ timeline }: { timeline: string }) => ({

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standalone: Story = {
args: {
context: 'standalone',
},
};
export const InModal: Story = {
args: {
context: 'modal',
},
};
export const ArchetypeOracle: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'oracle',
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
};
export const NoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'booster',
}),
},
},
};
export const NoNewPosts: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'pollster',
top_hashtag: SAMPLE_HASHTAG,
without_posts: true,
}),
},
},
};
export const NoNewPostsNoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'replier',
without_posts: true,
}),
},
},
};

View File

@@ -6,7 +6,6 @@ import classNames from 'classnames';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button';
import { me } from '@/flavours/glitch/initial_state';
import type { Account } from '@/flavours/glitch/models/account';
import type {
AnnualReport,
@@ -112,11 +111,11 @@ const illustrations = {
export const Archetype: React.FC<{
report: AnnualReport;
account?: Account;
canShare: boolean;
}> = ({ report, account, canShare }) => {
context: 'modal' | 'standalone';
}> = ({ report, account, context }) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = account?.id === me;
const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView);
const reveal = useCallback(() => {
@@ -209,7 +208,7 @@ export const Archetype: React.FC<{
/>
</Button>
)}
{isRevealed && canShare && <ShareButton report={report} />}
{isRevealed && isSelfView && <ShareButton report={report} />}
</div>
);
};

View File

@@ -19,7 +19,8 @@ const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data;
const statusId = by_reblogs || by_favourites || by_replies;
@@ -68,10 +69,10 @@ export const HighlightedPost: React.FC<{
defaultMessage='Most popular post'
/>
</h2>
<p>{label}</p>
{context === 'modal' && <p>{label}</p>}
</div>
<StatusQuoteManager showActions={false} id={`${statusId}`} />
<StatusQuoteManager showActions={false} id={statusId} />
</div>
);
};

View File

@@ -1,3 +1,14 @@
$mobile-breakpoint: 540px;
@font-face {
font-family: silkscreen-wrapstodon;
src: url('@/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2')
format('woff2');
font-weight: normal;
font-display: swap;
font-style: normal;
}
.modalWrapper {
position: absolute;
inset: 0;
@@ -9,22 +20,33 @@
pointer-events: none;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding-inline: 10px;
}
.loading-indicator .circular-progress {
color: var(--lime);
}
}
.closeButton {
position: absolute;
top: 24px;
right: 24px;
padding: 8px;
border-radius: 100%;
--default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-primary);
--corner-distance: 18px;
position: absolute;
top: var(--corner-distance);
right: var(--corner-distance);
padding: 8px;
border-radius: 100%;
@media (width < $mobile-breakpoint) {
--corner-distance: 16px;
padding: 4px;
}
}
.wrapper {
@@ -45,8 +67,10 @@
var(--color-bg-primary);
border-radius: 40px;
@media (width < 600px) {
padding: 12px;
@media (width < $mobile-breakpoint) {
padding-inline: 12px;
padding-bottom: 12px;
border-radius: 28px;
}
&::after {
@@ -65,13 +89,16 @@
text-align: center;
h1 {
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 30px;
font-weight: 600;
line-height: 1.5;
font-family: silkscreen-wrapstodon, monospace;
font-size: 28px;
line-height: 1;
margin-bottom: 8px;
padding-inline: 40px; // Prevent overlap with close button
@media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
}
p {
@@ -150,6 +177,10 @@
font-weight: 500;
line-height: 1;
overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
}
.mostBoostedPost {
@@ -166,7 +197,7 @@
'followers hashtag'
'new-posts hashtag';
@media (width < 680px) {
@media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
'followers new-posts'
@@ -191,7 +222,7 @@
grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag';
@media (width < 680px) {
@media (width < $mobile-breakpoint) {
grid-template-areas:
'number number'
'hashtag hashtag';
@@ -230,6 +261,10 @@
flex-direction: column;
align-items: center;
gap: 12px;
p {
max-width: 460px;
}
}
.archetypeArtboard {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import { defineMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router';
@@ -28,7 +28,6 @@ export const shareMessage = defineMessage({
defaultMessage: 'I got the {archetype} archetype!',
});
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone',
}) => {
@@ -67,23 +66,16 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
0,
);
const newFollowerCount = report.data.time_series.reduce(
(sum, item) => sum + item.followers,
0,
);
const newFollowerCount =
context === 'modal' &&
report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
const topHashtag = report.data.top_hashtags[0];
return (
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}>
<h1>
<FormattedMessage
id='annual_report.summary.title'
defaultMessage='Wrapstodon {year}'
values={{ year: report.year }}
/>
</h1>
<h1>Wrapstodon {report.year}</h1>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton
@@ -100,7 +92,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
</div>
<div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} />
<HighlightedPost data={report.data.top_statuses} context={context} />
<div
className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag,
@@ -110,13 +102,15 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
>
{!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && <MostUsedHashtag hashtag={topHashtag} />}
{topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div>
<Archetype
report={report}
account={account}
canShare={context === 'modal'}
/>
<Archetype report={report} account={account} context={context} />
</div>
</div>
);

View File

@@ -8,7 +8,9 @@ import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount;
}> = ({ hashtag }) => {
name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return (
<div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
@@ -23,11 +25,21 @@ export const MostUsedHashtag: React.FC<{
<div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
{context === 'modal' ? (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p>
</div>
);

View File

@@ -1,11 +1,12 @@
.wrapper {
max-width: max-content;
margin: 40px auto;
margin-inline: auto;
padding: 40px 10px;
}
.footer {
text-align: center;
margin-top: 1rem;
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;

View File

@@ -1,5 +1,7 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/flavours/glitch/components/logo';
import { AnnualReport } from './index';
@@ -11,7 +13,11 @@ export const WrapstodonSharedPage: FC = () => {
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
Generated with by the Mastodon team
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '♥' }}
/>
</footer>
</main>
);

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/flavours/glitch/actions/timelines';
import { scrollTopTimeline, loadPending } from '@/flavours/glitch/actions/timelines';
import { isNonStatusId } from '@/flavours/glitch/actions/timelines_typed';
import StatusList from '@/flavours/glitch/components/status_list';
import { me } from '@/flavours/glitch/initial_state';
import { TIMELINE_WRAPSTODON } from '@/flavours/glitch/reducers/slices/annual_report';
const getRegex = createSelector([
(state, { regex }) => regex,
@@ -29,7 +29,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
getRegex,
], (columnSettings, statusIds, statuses, regex) => {
return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true;
if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id);

View File

@@ -16,9 +16,9 @@ export interface TimeSeriesMonth {
}
export interface TopStatuses {
by_reblogs: number;
by_favourites: number;
by_replies: number;
by_reblogs: string;
by_favourites: string;
by_replies: string;
}
export type Archetype =

View File

@@ -6,6 +6,7 @@ import {
importFetchedStatuses,
} from '@/flavours/glitch/actions/importer';
import { insertIntoTimeline } from '@/flavours/glitch/actions/timelines';
import { timelineDelete } from '@/flavours/glitch/actions/timelines_typed';
import type { ApiAnnualReportState } from '@/flavours/glitch/api/annual_report';
import {
apiGetAnnualReport,
@@ -78,6 +79,25 @@ export const checkAnnualReport = createAppThunk(
},
);
export const reinsertAnnualReport = createAppThunk(
`${annualReportSlice.name}/reinsertAnnualReport`,
(_arg: unknown, { dispatch, getState }) => {
dispatch(
timelineDelete({
statusId: TIMELINE_WRAPSTODON,
accountId: '',
references: [],
reblogOf: null,
}),
);
const { state } = getState().annualReport;
if (!state || state === 'ineligible') {
return;
}
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
},
);
const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => {

View File

@@ -1,6 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
import { timelineDelete, isNonStatusId } from 'flavours/glitch/actions/timelines_typed';
import {
blockAccountSuccess,
@@ -19,7 +19,6 @@ import {
TIMELINE_MARK_AS_PARTIAL,
TIMELINE_INSERT,
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
disconnectTimeline,
} from '../actions/timelines';
import { compareId } from '../compare_id';
@@ -36,7 +35,6 @@ const initialTimeline = ImmutableMap({
items: ImmutableList(),
});
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because:
@@ -69,20 +67,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
// First, find the furthest (if properly sorted, oldest) item in the timeline that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item.
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1;
const lastIndex = oldIds.findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.last()) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
// is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newIds` (or that were deleted from the server, so should be removed
// anyway).
// Stop the gap *after* that item.
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1;
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.first()) > 0) + 1;
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
// It is possible, though unlikely, that the slice we are replacing contains items older
// than the elements we got from the API. Get them and add them back at the back of the
// slice.
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0);
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isNonStatusId(id) && compareId(id, newIds.last()) < 0);
insertedIds.union(olderIds);
// Make sure we aren't inserting duplicates

View File

@@ -6,7 +6,7 @@
html {
@include base.palette;
&[data-user-theme='system'] {
&:where([data-user-theme='system']) {
color-scheme: dark light;
@media (prefers-color-scheme: dark) {

View File

@@ -0,0 +1,100 @@
Below you'll find the original License file for the Silkscreen font.
The file used on Mastodon is a custom file subset to only include the
characters "Wrapstodon 0123456789" using the Font Squirrel Font-face Generator
(https://www.fontsquirrel.com/tools/webfont-generator)
-----------------------------------------------------------
Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,12 +1,13 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { reinsertAnnualReport, TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
import api, { getLinks } from 'mastodon/api';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed';
import { timelineDelete } from './timelines_typed';
export { disconnectTimeline } from './timelines_typed';
@@ -24,9 +25,16 @@ export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
TIMELINE_WRAPSTODON,
];
export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING,
timeline,
@@ -124,6 +132,7 @@ export function expandTimeline(timelineId, path, params = {}) {
if (timelineId === 'home') {
dispatch(submitMarkers());
dispatch(reinsertAnnualReport())
}
} catch(error) {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));

View File

@@ -2,6 +2,12 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
export function isNonStatusId(value: unknown) {
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
}
export const disconnectTimeline = createAction(
'timeline/disconnect',
({ timeline }: { timeline: string }) => ({

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
} from '@/testing/factories';
import { AnnualReport } from '.';
const SAMPLE_HASHTAG = {
name: 'Mastodon',
count: 14,
};
const meta = {
title: 'Components/AnnualReport',
component: AnnualReport,
args: {
context: 'standalone',
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
},
annualReport: annualReportFactory({
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
} satisfies Meta<typeof AnnualReport>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standalone: Story = {
args: {
context: 'standalone',
},
};
export const InModal: Story = {
args: {
context: 'modal',
},
};
export const ArchetypeOracle: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'oracle',
top_hashtag: SAMPLE_HASHTAG,
}),
},
},
};
export const NoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'booster',
}),
},
},
};
export const NoNewPosts: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'pollster',
top_hashtag: SAMPLE_HASHTAG,
without_posts: true,
}),
},
},
};
export const NoNewPostsNoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
archetype: 'replier',
without_posts: true,
}),
},
},
};

View File

@@ -12,7 +12,6 @@ import replier from '@/images/archetypes/replier.png';
import space_elements from '@/images/archetypes/space_elements.png';
import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button';
import { me } from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import type {
AnnualReport,
@@ -112,11 +111,11 @@ const illustrations = {
export const Archetype: React.FC<{
report: AnnualReport;
account?: Account;
canShare: boolean;
}> = ({ report, account, canShare }) => {
context: 'modal' | 'standalone';
}> = ({ report, account, context }) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const isSelfView = account?.id === me;
const isSelfView = context === 'modal';
const [isRevealed, setIsRevealed] = useState(!isSelfView);
const reveal = useCallback(() => {
@@ -209,7 +208,7 @@ export const Archetype: React.FC<{
/>
</Button>
)}
{isRevealed && canShare && <ShareButton report={report} />}
{isRevealed && isSelfView && <ShareButton report={report} />}
</div>
);
};

View File

@@ -19,7 +19,8 @@ const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
context: 'modal' | 'standalone';
}> = ({ data, context }) => {
const { by_reblogs, by_favourites, by_replies } = data;
const statusId = by_reblogs || by_favourites || by_replies;
@@ -68,10 +69,10 @@ export const HighlightedPost: React.FC<{
defaultMessage='Most popular post'
/>
</h2>
<p>{label}</p>
{context === 'modal' && <p>{label}</p>}
</div>
<StatusQuoteManager showActions={false} id={`${statusId}`} />
<StatusQuoteManager showActions={false} id={statusId} />
</div>
);
};

View File

@@ -1,3 +1,14 @@
$mobile-breakpoint: 540px;
@font-face {
font-family: silkscreen-wrapstodon;
src: url('@/fonts/silkscreen-wrapstodon/silkscreen-regular.woff2')
format('woff2');
font-weight: normal;
font-display: swap;
font-style: normal;
}
.modalWrapper {
position: absolute;
inset: 0;
@@ -9,22 +20,33 @@
pointer-events: none;
scrollbar-color: var(--color-text-secondary) var(--color-bg-secondary);
@media (width < $mobile-breakpoint) {
padding-inline: 10px;
}
.loading-indicator .circular-progress {
color: var(--lime);
}
}
.closeButton {
position: absolute;
top: 24px;
right: 24px;
padding: 8px;
border-radius: 100%;
--default-icon-color: var(--color-bg-primary);
--default-bg-color: var(--color-text-primary);
--hover-icon-color: var(--color-bg-primary);
--hover-bg-color: var(--color-text-primary);
--corner-distance: 18px;
position: absolute;
top: var(--corner-distance);
right: var(--corner-distance);
padding: 8px;
border-radius: 100%;
@media (width < $mobile-breakpoint) {
--corner-distance: 16px;
padding: 4px;
}
}
.wrapper {
@@ -45,8 +67,10 @@
var(--color-bg-primary);
border-radius: 40px;
@media (width < 600px) {
padding: 12px;
@media (width < $mobile-breakpoint) {
padding-inline: 12px;
padding-bottom: 12px;
border-radius: 28px;
}
&::after {
@@ -65,13 +89,16 @@
text-align: center;
h1 {
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 30px;
font-weight: 600;
line-height: 1.5;
font-family: silkscreen-wrapstodon, monospace;
font-size: 28px;
line-height: 1;
margin-bottom: 8px;
padding-inline: 40px; // Prevent overlap with close button
@media (width < $mobile-breakpoint) {
font-size: 22px;
margin-bottom: 4px;
}
}
p {
@@ -150,6 +177,10 @@
font-weight: 500;
line-height: 1;
overflow-wrap: break-word;
@media (width < $mobile-breakpoint) {
font-size: 24px;
}
}
.mostBoostedPost {
@@ -166,7 +197,7 @@
'followers hashtag'
'new-posts hashtag';
@media (width < 680px) {
@media (width < $mobile-breakpoint) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
'followers new-posts'
@@ -191,7 +222,7 @@
grid-template-columns: 1fr 2fr;
grid-template-areas: 'number hashtag';
@media (width < 680px) {
@media (width < $mobile-breakpoint) {
grid-template-areas:
'number number'
'hashtag hashtag';
@@ -230,6 +261,10 @@
flex-direction: column;
align-items: center;
gap: 12px;
p {
max-width: 460px;
}
}
.archetypeArtboard {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { FC } from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import { defineMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router';
@@ -28,7 +28,6 @@ export const shareMessage = defineMessage({
defaultMessage: 'I got the {archetype} archetype!',
});
// Share = false when using the embedded version of the report.
export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
context = 'standalone',
}) => {
@@ -67,23 +66,16 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
0,
);
const newFollowerCount = report.data.time_series.reduce(
(sum, item) => sum + item.followers,
0,
);
const newFollowerCount =
context === 'modal' &&
report.data.time_series.reduce((sum, item) => sum + item.followers, 0);
const topHashtag = report.data.top_hashtags[0];
return (
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.header}>
<h1>
<FormattedMessage
id='annual_report.summary.title'
defaultMessage='Wrapstodon {year}'
values={{ year: report.year }}
/>
</h1>
<h1>Wrapstodon {report.year}</h1>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton
@@ -100,7 +92,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
</div>
<div className={styles.stack}>
<HighlightedPost data={report.data.top_statuses} />
<HighlightedPost data={report.data.top_statuses} context={context} />
<div
className={moduleClassNames(styles.statsGrid, {
noHashtag: !topHashtag,
@@ -110,13 +102,15 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
>
{!!newFollowerCount && <Followers count={newFollowerCount} />}
{!!newPostCount && <NewPosts count={newPostCount} />}
{topHashtag && <MostUsedHashtag hashtag={topHashtag} />}
{topHashtag && (
<MostUsedHashtag
hashtag={topHashtag}
name={account?.display_name}
context={context}
/>
)}
</div>
<Archetype
report={report}
account={account}
canShare={context === 'modal'}
/>
<Archetype report={report} account={account} context={context} />
</div>
</div>
);

View File

@@ -8,7 +8,9 @@ import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount;
}> = ({ hashtag }) => {
name: string | undefined;
context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => {
return (
<div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
@@ -23,11 +25,21 @@ export const MostUsedHashtag: React.FC<{
<div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
{context === 'modal' ? (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }}
/>
) : (
name && (
<FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }}
/>
)
)}
</p>
</div>
);

View File

@@ -1,11 +1,12 @@
.wrapper {
max-width: max-content;
margin: 40px auto;
margin-inline: auto;
padding: 40px 10px;
}
.footer {
text-align: center;
margin-top: 1rem;
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;

View File

@@ -1,5 +1,7 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/mastodon/components/logo';
import { AnnualReport } from './index';
@@ -11,7 +13,11 @@ export const WrapstodonSharedPage: FC = () => {
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
Generated with by the Mastodon team
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '♥' }}
/>
</footer>
</main>
);

View File

@@ -4,10 +4,10 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/mastodon/actions/timelines';
import { scrollTopTimeline, loadPending } from '@/mastodon/actions/timelines';
import { isNonStatusId } from '@/mastodon/actions/timelines_typed';
import StatusList from '@/mastodon/components/status_list';
import { me } from '@/mastodon/initial_state';
import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report';
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
@@ -15,7 +15,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
(state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => {
if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true;
if (isNonStatusId(id)) return true;
const statusForId = statuses.get(id);

View File

@@ -117,6 +117,7 @@
"annual_report.announcement.action_view": "View my Wrapstodon",
"annual_report.announcement.description": "Discover more about your engagement on Mastodon over the past year.",
"annual_report.announcement.title": "Wrapstodon {year} has arrived",
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
"annual_report.summary.archetype.booster.desc_public": "{name} stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.desc_self": "You stayed on the hunt for posts to boost, amplifying other creators with perfect aim.",
"annual_report.summary.archetype.booster.name": "The Archer",
@@ -146,11 +147,11 @@
"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.most_used_hashtag.used_count": "You included this hashtag in {count, plural, one {one post} other {# posts}}.",
"annual_report.summary.most_used_hashtag.used_count_public": "{name} included this hashtag in {count, plural, one {one post} other {# posts}}.",
"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 {domain} users.</bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "We won't tell Bernie.",
"annual_report.summary.share_message": "I got the {archetype} archetype!",
"annual_report.summary.title": "Wrapstodon {year}",
"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.",

View File

@@ -16,9 +16,9 @@ export interface TimeSeriesMonth {
}
export interface TopStatuses {
by_reblogs: number;
by_favourites: number;
by_replies: number;
by_reblogs: string;
by_favourites: string;
by_replies: string;
}
export type Archetype =

View File

@@ -6,6 +6,7 @@ import {
importFetchedStatuses,
} from '@/mastodon/actions/importer';
import { insertIntoTimeline } from '@/mastodon/actions/timelines';
import { timelineDelete } from '@/mastodon/actions/timelines_typed';
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import {
apiGetAnnualReport,
@@ -78,6 +79,25 @@ export const checkAnnualReport = createAppThunk(
},
);
export const reinsertAnnualReport = createAppThunk(
`${annualReportSlice.name}/reinsertAnnualReport`,
(_arg: unknown, { dispatch, getState }) => {
dispatch(
timelineDelete({
statusId: TIMELINE_WRAPSTODON,
accountId: '',
references: [],
reblogOf: null,
}),
);
const { state } = getState().annualReport;
if (!state || state === 'ineligible') {
return;
}
dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1));
},
);
const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => {

View File

@@ -1,6 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
import { timelineDelete, isNonStatusId } from 'mastodon/actions/timelines_typed';
import {
blockAccountSuccess,
@@ -19,7 +19,6 @@ import {
TIMELINE_MARK_AS_PARTIAL,
TIMELINE_INSERT,
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
disconnectTimeline,
} from '../actions/timelines';
import { compareId } from '../compare_id';
@@ -36,7 +35,6 @@ const initialTimeline = ImmutableMap({
items: ImmutableList(),
});
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because:
@@ -69,20 +67,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
// First, find the furthest (if properly sorted, oldest) item in the timeline that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item.
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1;
const lastIndex = oldIds.findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.last()) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
// is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newIds` (or that were deleted from the server, so should be removed
// anyway).
// Stop the gap *after* that item.
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1;
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isNonStatusId(id) && compareId(id, newIds.first()) > 0) + 1;
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
// It is possible, though unlikely, that the slice we are replacing contains items older
// than the elements we got from the API. Get them and add them back at the back of the
// slice.
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0);
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isNonStatusId(id) && compareId(id, newIds.last()) < 0);
insertedIds.union(olderIds);
// Make sure we aren't inserting duplicates

View File

@@ -6,7 +6,7 @@
html {
@include base.palette;
&[data-user-theme='system'] {
&:where([data-user-theme='system']) {
color-scheme: dark light;
@media (prefers-color-scheme: dark) {

View File

@@ -1,4 +1,4 @@
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, List } from 'immutable';
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
import type { ApiStatusJSON } from '@/mastodon/api_types/statuses';
@@ -7,6 +7,7 @@ import type {
UnicodeEmojiData,
} from '@/mastodon/features/emoji/types';
import { createAccountFromServerJSON } from '@/mastodon/models/account';
import type { AnnualReport } from '@/mastodon/models/annual_report';
import type { Status } from '@/mastodon/models/status';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
@@ -75,16 +76,18 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
mentions: [],
tags: [],
emojis: [],
content: '<p>This is a test status.</p>',
contentHtml: '<p>This is a test status.</p>',
...data,
});
export const statusFactoryState = (
options: FactoryOptions<ApiStatusJSON> = {},
) =>
ImmutableMap<string, unknown>(
statusFactory(options) as unknown as Record<string, unknown>,
) as unknown as Status;
ImmutableMap<string, unknown>({
...(statusFactory(options) as unknown as Record<string, unknown>),
account: options.account?.id ?? '1',
tags: List(options.tags),
}) as unknown as Status;
export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
id,
@@ -130,3 +133,119 @@ export function customEmojiFactory(
...data,
};
}
interface AnnualReportState {
state: 'available';
report: AnnualReport;
}
interface AnnualReportFactoryOptions {
account_id?: string;
status_id?: string;
archetype?: AnnualReport['data']['archetype'];
year?: number;
top_hashtag?: AnnualReport['data']['top_hashtags'][0];
without_posts?: boolean;
}
export function annualReportFactory({
account_id = '1',
status_id = '1',
archetype = 'lurker',
year,
top_hashtag,
without_posts = false,
}: AnnualReportFactoryOptions = {}): AnnualReportState {
return {
state: 'available',
report: {
schema_version: 2,
share_url: '#',
account_id,
year: year ?? 2025,
data: {
archetype,
time_series: [
{
month: 1,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 2,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 3,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 4,
statuses: 0,
followers: 0,
following: 0,
},
{
month: 5,
statuses: without_posts ? 0 : 1,
followers: 1,
following: 3,
},
{
month: 6,
statuses: without_posts ? 0 : 7,
followers: 1,
following: 0,
},
{
month: 7,
statuses: without_posts ? 0 : 2,
followers: 0,
following: 0,
},
{
month: 8,
statuses: without_posts ? 0 : 2,
followers: 0,
following: 0,
},
{
month: 9,
statuses: without_posts ? 0 : 11,
followers: 0,
following: 1,
},
{
month: 10,
statuses: without_posts ? 0 : 12,
followers: 0,
following: 1,
},
{
month: 11,
statuses: without_posts ? 0 : 6,
followers: 0,
following: 1,
},
{
month: 12,
statuses: without_posts ? 0 : 4,
followers: 0,
following: 0,
},
],
top_hashtags: top_hashtag ? [top_hashtag] : [],
top_statuses: {
by_reblogs: status_id,
by_replies: status_id,
by_favourites: status_id,
},
},
},
};
}

View File

@@ -3,29 +3,24 @@
class AnnualReport::TimeSeries < AnnualReport::Source
def generate
{
time_series: (1..12).map do |month|
{
month: month,
statuses: statuses_per_month[month] || 0,
following: following_per_month[month] || 0,
followers: followers_per_month[month] || 0,
}
end,
time_series: [
{
month: 12,
statuses: statuses_this_year,
followers: followers_this_year,
},
],
}
end
private
def statuses_per_month
@statuses_per_month ||= report_statuses.group(:period).pluck(date_part_month.as('period'), Arel.star.count).to_h
def statuses_this_year
@statuses_this_year ||= report_statuses.count
end
def following_per_month
@following_per_month ||= annual_relationships_by_month(@account.active_relationships)
end
def followers_per_month
@followers_per_month ||= annual_relationships_by_month(@account.passive_relationships)
def followers_this_year
@followers_this_year ||= @account.passive_relationships.where(created_in_year, @year).count
end
def date_part_month
@@ -34,14 +29,6 @@ class AnnualReport::TimeSeries < AnnualReport::Source
SQL
end
def annual_relationships_by_month(relationships)
relationships
.where(created_in_year, @year)
.group(:period)
.pluck(date_part_month.as('period'), Arel.star.count)
.to_h
end
def created_in_year
Arel.sql(<<~SQL.squish)
DATE_PART('year', created_at) = ?

View File

@@ -0,0 +1,4 @@
- description = t('wrapstodon.description', name: display_name(account))
%meta{ name: 'description', content: description }/
= opengraph 'og:description', description

View File

@@ -4,8 +4,12 @@
%meta{ name: 'robots', content: 'noindex, noarchive' }/
= opengraph 'og:site_name', site_title
= opengraph 'og:type', 'article'
= opengraph 'og:title', t('wrapstodon.title', name: display_name(@account), year: @generated_annual_report.year)
= opengraph 'profile:username', acct(@account)[1..]
= render 'og_description', account: @account
= flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
- content_for :html_classes, 'theme-dark'

View File

@@ -2187,4 +2187,5 @@ en:
otp_required: To use security keys please enable two-factor authentication first.
registered_on: Registered on %{date}
wrapstodon:
description: See how %{name} used Mastodon this year!
title: Wrapstodon %{year} for %{name}

View File

@@ -59,7 +59,7 @@ services:
web:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: .
image: ghcr.io/glitch-soc/mastodon:v4.5.2
image: ghcr.io/glitch-soc/mastodon:v4.5.3
restart: always
env_file: .env.production
command: bundle exec puma -C config/puma.rb
@@ -83,7 +83,7 @@ services:
# build:
# dockerfile: ./streaming/Dockerfile
# context: .
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.2
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.3
restart: always
env_file: .env.production
command: node ./streaming/index.js
@@ -102,7 +102,7 @@ services:
sidekiq:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: .
image: ghcr.io/glitch-soc/mastodon:v4.5.2
image: ghcr.io/glitch-soc/mastodon:v4.5.3
restart: always
env_file: .env.production
command: bundle exec sidekiq

View File

@@ -13,7 +13,7 @@ RSpec.describe AnnualReport::TimeSeries do
expect(subject.generate)
.to include(
time_series: match(
include(followers: 0, following: 0, month: 1, statuses: 0)
include(followers: 0, month: 12, statuses: 0)
)
)
end
@@ -37,7 +37,7 @@ RSpec.describe AnnualReport::TimeSeries do
expect(subject.generate)
.to include(
time_series: match(
include(followers: 1, following: 1, month: 1, statuses: 1)
include(followers: 1, month: 12, statuses: 1)
)
)
end

123
yarn.lock
View File

@@ -1670,6 +1670,15 @@ __metadata:
languageName: node
linkType: hard
"@csstools/postcss-position-area-property@npm:^1.0.0":
version: 1.0.0
resolution: "@csstools/postcss-position-area-property@npm:1.0.0"
peerDependencies:
postcss: ^8.4
checksum: 10c0/38f770454d46bfed01d43a3f5e7ac07d3111399b374a7198ae6503cdb6288e410c7b4199f5a7af8f16aeb688216445ade97be417c084313d6c56f55e50d34559
languageName: node
linkType: hard
"@csstools/postcss-progressive-custom-properties@npm:^4.2.1":
version: 4.2.1
resolution: "@csstools/postcss-progressive-custom-properties@npm:4.2.1"
@@ -1746,6 +1755,18 @@ __metadata:
languageName: node
linkType: hard
"@csstools/postcss-system-ui-font-family@npm:^1.0.0":
version: 1.0.0
resolution: "@csstools/postcss-system-ui-font-family@npm:1.0.0"
dependencies:
"@csstools/css-parser-algorithms": "npm:^3.0.5"
"@csstools/css-tokenizer": "npm:^3.0.4"
peerDependencies:
postcss: ^8.4
checksum: 10c0/6a81761ae3cae643659b1416a7a892cf1505474896193b8abc26cff319cb6b1a20b64c5330d64019fba458e058da3abc9407d0ebf0c102289c0b79ef99b4c6d6
languageName: node
linkType: hard
"@csstools/postcss-text-decoration-shorthand@npm:^4.0.3":
version: 4.0.3
resolution: "@csstools/postcss-text-decoration-shorthand@npm:4.0.3"
@@ -5399,13 +5420,13 @@ __metadata:
languageName: node
linkType: hard
"autoprefixer@npm:^10.4.21":
version: 10.4.21
resolution: "autoprefixer@npm:10.4.21"
"autoprefixer@npm:^10.4.22":
version: 10.4.22
resolution: "autoprefixer@npm:10.4.22"
dependencies:
browserslist: "npm:^4.24.4"
caniuse-lite: "npm:^1.0.30001702"
fraction.js: "npm:^4.3.7"
browserslist: "npm:^4.27.0"
caniuse-lite: "npm:^1.0.30001754"
fraction.js: "npm:^5.3.4"
normalize-range: "npm:^0.1.2"
picocolors: "npm:^1.1.1"
postcss-value-parser: "npm:^4.2.0"
@@ -5413,7 +5434,7 @@ __metadata:
postcss: ^8.1.0
bin:
autoprefixer: bin/autoprefixer
checksum: 10c0/de5b71d26d0baff4bbfb3d59f7cf7114a6030c9eeb66167acf49a32c5b61c68e308f1e0f869d92334436a221035d08b51cd1b2f2c4689b8d955149423c16d4d4
checksum: 10c0/2ae8d135af2deaaa5065a3a466c877787373c0ed766b8a8e8259d7871db79c1a7e1d9f6c9541c54fa95647511d3c2066bb08a30160e58c9bfa75506f9c18f3aa
languageName: node
linkType: hard
@@ -5538,12 +5559,12 @@ __metadata:
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.8.3":
version: 2.8.6
resolution: "baseline-browser-mapping@npm:2.8.6"
"baseline-browser-mapping@npm:^2.9.0":
version: 2.9.2
resolution: "baseline-browser-mapping@npm:2.9.2"
bin:
baseline-browser-mapping: dist/cli.js
checksum: 10c0/ea628db5048d1e5c0251d4783e0496f5ce8de7a0e20ea29c8876611cb0acf58ffc76bf6561786c6388db22f130646e3ecb91eebc1c03954552a21d38fa38320f
checksum: 10c0/4f9be09e20261ed26f19e9b95454dcb8d8371b87983c57cd9f70b9572e9b3053577f0d8d6d91297bdb605337747680686e22f62522a6e57ae2488fcacf641188
languageName: node
linkType: hard
@@ -5635,18 +5656,18 @@ __metadata:
languageName: node
linkType: hard
"browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.25.1, browserslist@npm:^4.26.0":
version: 4.26.2
resolution: "browserslist@npm:4.26.2"
"browserslist@npm:^4.24.0, browserslist@npm:^4.25.1, browserslist@npm:^4.27.0, browserslist@npm:^4.28.0":
version: 4.28.1
resolution: "browserslist@npm:4.28.1"
dependencies:
baseline-browser-mapping: "npm:^2.8.3"
caniuse-lite: "npm:^1.0.30001741"
electron-to-chromium: "npm:^1.5.218"
node-releases: "npm:^2.0.21"
update-browserslist-db: "npm:^1.1.3"
baseline-browser-mapping: "npm:^2.9.0"
caniuse-lite: "npm:^1.0.30001759"
electron-to-chromium: "npm:^1.5.263"
node-releases: "npm:^2.0.27"
update-browserslist-db: "npm:^1.2.0"
bin:
browserslist: cli.js
checksum: 10c0/1146339dad33fda77786b11ea07f1c40c48899edd897d73a9114ee0dbb1ee6475bb4abda263a678c104508bdca8e66760ff8e10be1947d3e20d34bae01d8b89b
checksum: 10c0/545a5fa9d7234e3777a7177ec1e9134bb2ba60a69e6b95683f6982b1473aad347c77c1264ccf2ac5dea609a9731fbfbda6b85782bdca70f80f86e28a402504bd
languageName: node
linkType: hard
@@ -5753,10 +5774,10 @@ __metadata:
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001702, caniuse-lite@npm:^1.0.30001741":
version: 1.0.30001743
resolution: "caniuse-lite@npm:1.0.30001743"
checksum: 10c0/1bd730ca10d881a1ca9f55ce864d34c3b18501718c03976e0d3419f4694b715159e13fdef6d58ad47b6d2445d315940f3a01266658876828c820a3331aac021d
"caniuse-lite@npm:^1.0.30001754, caniuse-lite@npm:^1.0.30001759":
version: 1.0.30001759
resolution: "caniuse-lite@npm:1.0.30001759"
checksum: 10c0/b0f415960ba34995cda18e0d25c4e602f6917b9179290a76bdd0311423505b78cc93e558a90c98a22a1cc6b1781ab720ef6beea24ec7e29a1c1164ca72eac3a2
languageName: node
linkType: hard
@@ -6216,10 +6237,10 @@ __metadata:
languageName: node
linkType: hard
"cssdb@npm:^8.4.2":
version: 8.4.2
resolution: "cssdb@npm:8.4.2"
checksum: 10c0/3c88610ba9e3f87f9ecf068b72261e90de8bb1f5d1dceefc79ff42b2e19f5814135937ad057b7f8c4bf58212f911e5f9d2f6f0910af3da127170009f1f75689c
"cssdb@npm:^8.5.2":
version: 8.5.2
resolution: "cssdb@npm:8.5.2"
checksum: 10c0/12f7ed29dda0d74b209d6470acd246b335aac507c2786c17f20709f856eabb24e6d43ff44507898f5a1b0a101b286b997d95682e44b06f4c7cb4bd7081db7c32
languageName: node
linkType: hard
@@ -6551,10 +6572,10 @@ __metadata:
languageName: node
linkType: hard
"electron-to-chromium@npm:^1.5.218":
version: 1.5.222
resolution: "electron-to-chromium@npm:1.5.222"
checksum: 10c0/a81eb8d2b171236884faf9b5dd382c66d9250283032cb89a3e555d788bf3956f7f4f6bf7bf30b3daf9e5c945ef837bfcd1be21b3f41cfe186ed2f25da13c9af3
"electron-to-chromium@npm:^1.5.263":
version: 1.5.266
resolution: "electron-to-chromium@npm:1.5.266"
checksum: 10c0/74ada92ada1ace76ec5b7da8a9cc2d7f03db122a64ac8e12ae30eba3e358ffec443c0c5265bc6edcdeebfa73f449b21c361080c064eb1eec437db2d71fc03248
languageName: node
linkType: hard
@@ -7652,10 +7673,10 @@ __metadata:
languageName: node
linkType: hard
"fraction.js@npm:^4.3.7":
version: 4.3.7
resolution: "fraction.js@npm:4.3.7"
checksum: 10c0/df291391beea9ab4c263487ffd9d17fed162dbb736982dee1379b2a8cc94e4e24e46ed508c6d278aded9080ba51872f1bc5f3a5fd8d7c74e5f105b508ac28711
"fraction.js@npm:^5.3.4":
version: 5.3.4
resolution: "fraction.js@npm:5.3.4"
checksum: 10c0/f90079fe9bfc665e0a07079938e8ff71115bce9462f17b32fc283f163b0540ec34dc33df8ed41bb56f028316b04361b9a9995b9ee9258617f8338e0b05c5f95a
languageName: node
linkType: hard
@@ -9908,10 +9929,10 @@ __metadata:
languageName: node
linkType: hard
"node-releases@npm:^2.0.21":
version: 2.0.21
resolution: "node-releases@npm:2.0.21"
checksum: 10c0/0eb94916eeebbda9d51da6a9ea47428a12b2bb0dd94930c949632b0c859356abf53b2e5a2792021f96c5fda4f791a8e195f2375b78ae7dba8d8bc3141baa1469
"node-releases@npm:^2.0.27":
version: 2.0.27
resolution: "node-releases@npm:2.0.27"
checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2
languageName: node
linkType: hard
@@ -10870,8 +10891,8 @@ __metadata:
linkType: hard
"postcss-preset-env@npm:^10.1.5":
version: 10.4.0
resolution: "postcss-preset-env@npm:10.4.0"
version: 10.5.0
resolution: "postcss-preset-env@npm:10.5.0"
dependencies:
"@csstools/postcss-alpha-function": "npm:^1.0.1"
"@csstools/postcss-cascade-layers": "npm:^5.0.2"
@@ -10900,21 +10921,23 @@ __metadata:
"@csstools/postcss-nested-calc": "npm:^4.0.0"
"@csstools/postcss-normalize-display-values": "npm:^4.0.0"
"@csstools/postcss-oklab-function": "npm:^4.0.12"
"@csstools/postcss-position-area-property": "npm:^1.0.0"
"@csstools/postcss-progressive-custom-properties": "npm:^4.2.1"
"@csstools/postcss-random-function": "npm:^2.0.1"
"@csstools/postcss-relative-color-syntax": "npm:^3.0.12"
"@csstools/postcss-scope-pseudo-class": "npm:^4.0.1"
"@csstools/postcss-sign-functions": "npm:^1.1.4"
"@csstools/postcss-stepped-value-functions": "npm:^4.0.9"
"@csstools/postcss-system-ui-font-family": "npm:^1.0.0"
"@csstools/postcss-text-decoration-shorthand": "npm:^4.0.3"
"@csstools/postcss-trigonometric-functions": "npm:^4.0.9"
"@csstools/postcss-unset-value": "npm:^4.0.0"
autoprefixer: "npm:^10.4.21"
browserslist: "npm:^4.26.0"
autoprefixer: "npm:^10.4.22"
browserslist: "npm:^4.28.0"
css-blank-pseudo: "npm:^7.0.1"
css-has-pseudo: "npm:^7.0.3"
css-prefers-color-scheme: "npm:^10.0.0"
cssdb: "npm:^8.4.2"
cssdb: "npm:^8.5.2"
postcss-attribute-case-insensitive: "npm:^7.0.1"
postcss-clamp: "npm:^4.1.0"
postcss-color-functional-notation: "npm:^7.0.12"
@@ -10942,7 +10965,7 @@ __metadata:
postcss-selector-not: "npm:^8.0.1"
peerDependencies:
postcss: ^8.4
checksum: 10c0/3c081a66ebde19ae2f915f4eb103b85097085799b43103e5dd1699ed807bd54c80d633c7d4b525badaf21e9d0b217e6ca169ee306e2b720bb70b7414ad375387
checksum: 10c0/4e9881478b465e8eb7493c1240cb2df8523944135728672e8feeb8bb3f6a48b00d67d007ee8fbdcee648ab9ebdfca10a7591f42e3c6b9076cbf7f355f8ad1574
languageName: node
linkType: hard
@@ -13880,9 +13903,9 @@ __metadata:
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.1.3":
version: 1.1.3
resolution: "update-browserslist-db@npm:1.1.3"
"update-browserslist-db@npm:^1.2.0":
version: 1.2.2
resolution: "update-browserslist-db@npm:1.2.2"
dependencies:
escalade: "npm:^3.2.0"
picocolors: "npm:^1.1.1"
@@ -13890,7 +13913,7 @@ __metadata:
browserslist: ">= 4.21.0"
bin:
update-browserslist-db: cli.js
checksum: 10c0/682e8ecbf9de474a626f6462aa85927936cdd256fe584c6df2508b0df9f7362c44c957e9970df55dfe44d3623807d26316ea2c7d26b80bb76a16c56c37233c32
checksum: 10c0/39c3ea08b397ffc8dc3a1c517f5c6ed5cc4179b5e185383dab9bf745879623c12062a2e6bf4f9427cc59389c7bfa0010e86858b923c1e349e32fdddd9b043bb2
languageName: node
linkType: hard