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.
This commit is contained in:
Claire
2025-12-10 18:05:44 +01:00
28 changed files with 592 additions and 153 deletions

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,
},
},
},
};
}