Merge pull request #3316 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 53be8392ec
This commit is contained in:
Claire
2025-12-16 19:09:07 +01:00
committed by GitHub
59 changed files with 630 additions and 301 deletions

View File

@@ -55,12 +55,31 @@ const preview: Preview = {
locale: 'en',
},
decorators: [
(Story, { parameters, globals, args }) => {
(Story, { parameters, globals, args, argTypes }) => {
// Get the locale from the global toolbar
// and merge it with any parameters or args state.
const { locale } = globals as { locale: string };
const { state = {} } = parameters;
const { state: argsState = {} } = args;
const argsState: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
const argType = argTypes[key];
if (argType?.reduxPath) {
const reduxPath = Array.isArray(argType.reduxPath)
? argType.reduxPath.map((p) => p.toString())
: argType.reduxPath.split('.');
reduxPath.reduce((acc, key, i) => {
if (acc[key] === undefined) {
acc[key] = {};
}
if (i === reduxPath.length - 1) {
acc[key] = value;
}
return acc[key] as Record<string, unknown>;
}, argsState);
}
}
const reducer = reducerWithInitialState(
{
@@ -69,7 +88,7 @@ const preview: Preview = {
},
},
state as Record<string, unknown>,
argsState as Record<string, unknown>,
argsState,
);
const store = configureStore({

View File

@@ -1,7 +1,20 @@
// The addon package.json incorrectly exports types, so we need to override them here.
import type { RootState } from '@/mastodon/store';
// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
declare module '@storybook/addon-vitest/vitest-plugin' {
export * from '@storybook/addon-vitest/dist/vitest-plugin/index';
}
type RootPathKeys = keyof RootState;
declare module 'storybook/internal/csf' {
export interface InputType {
reduxPath?:
| `${RootPathKeys}.${string}`
| [RootPathKeys, ...(string | number)[]];
}
}
export {};

View File

@@ -138,7 +138,7 @@ group :test do
# Browser integration testing
gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver'
gem 'playwright-ruby-client', '1.56.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
gem 'playwright-ruby-client', '1.57.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
# Used to reset the database between system tests
gem 'database_cleaner-active_record'

View File

@@ -96,7 +96,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1190.0)
aws-partitions (1.1194.0)
aws-sdk-core (3.239.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -108,7 +108,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.206.0)
aws-sdk-s3 (1.207.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -166,7 +166,7 @@ GEM
climate_control (1.2.0)
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
cose (1.3.1)
cbor (~> 0.5.9)
@@ -182,7 +182,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.5.0)
date (3.5.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -208,7 +208,7 @@ GEM
domain_name (0.6.20240107)
doorkeeper (5.8.2)
railties (>= 5)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
dry-cli (1.3.0)
elasticsearch (7.17.11)
@@ -227,11 +227,11 @@ GEM
mail (~> 2.7)
email_validator (2.2.4)
activemodel
erb (5.1.3)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
excon (1.3.0)
excon (1.3.2)
logger
fabrication (3.0.0)
faker (3.5.3)
@@ -244,8 +244,8 @@ GEM
faraday (>= 1, < 3)
faraday-httpclient (2.0.2)
httpclient (>= 2.2)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
fast_blank (1.0.1)
fastimage (2.4.0)
ffi (1.17.2)
@@ -269,20 +269,20 @@ GEM
fog-openstack (1.1.5)
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.2.1)
formatador (1.2.3)
reline
forwardable (1.3.3)
fugit (1.12.0)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
google-protobuf (4.32.1)
google-protobuf (4.33.2)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.22.0)
google-protobuf (~> 4.26)
haml (6.4.0)
haml (7.1.0)
temple (>= 0.8.2)
thor
tilt
@@ -291,7 +291,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.67.0)
haml_lint (0.68.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@@ -340,7 +340,7 @@ GEM
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.1)
io-console (0.8.2)
irb (1.15.3)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
@@ -350,7 +350,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.16.0)
json (2.18.0)
json-canonicalization (1.0.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
@@ -447,13 +447,13 @@ GEM
mime-types-data (3.2025.0924)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2)
minitest (5.27.0)
msgpack (1.8.0)
multi_json (1.17.0)
multi_json (1.18.0)
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.12)
net-imap (0.6.0)
date
net-protocol
net-ldap (0.20.0)
@@ -465,7 +465,7 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -594,7 +594,7 @@ GEM
pg (1.6.2)
pghero (3.7.0)
activerecord (>= 7.1)
playwright-ruby-client (1.56.0)
playwright-ruby-client (1.57.0)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
pp (0.6.3)
@@ -615,7 +615,7 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.2.6)
psych (5.3.0)
date
stringio
public_suffix (7.0.0)
@@ -631,7 +631,7 @@ GEM
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-oauth2 (2.2.1)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
@@ -649,7 +649,7 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.3)
actioncable (= 8.0.3)
@@ -695,7 +695,7 @@ GEM
readline (~> 0.0)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.15.1)
rdoc (6.17.0)
erb
psych (>= 4.0.0)
tsort
@@ -721,18 +721,18 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rspec (3.13.1)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.5)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-github (3.0.0)
rspec-core (~> 3.0)
rspec-mocks (3.13.5)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
@@ -820,7 +820,7 @@ GEM
sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2)
sidekiq (>= 7.3, < 9)
sidekiq-unique-jobs (8.0.11)
sidekiq-unique-jobs (8.0.12)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
thor (>= 1.0, < 3.0)
@@ -842,7 +842,7 @@ GEM
stoplight (5.7.0)
concurrent-ruby
zeitwerk
stringio (3.1.8)
stringio (3.1.9)
strong_migrations (2.5.1)
activerecord (>= 7.1)
swd (2.0.3)
@@ -859,7 +859,7 @@ GEM
test-prof (1.5.0)
thor (1.4.0)
tilt (2.6.1)
timeout (0.4.3)
timeout (0.5.0)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
@@ -880,7 +880,7 @@ GEM
unf (~> 0.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.2)
tzinfo-data (1.2025.3)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
@@ -920,7 +920,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrick (1.9.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
@@ -1033,7 +1033,7 @@ DEPENDENCIES
parslet
pg (~> 1.5)
pghero
playwright-ruby-client (= 1.56.0)
playwright-ruby-client (= 1.57.0)
premailer-rails
prometheus_exporter (~> 2.2)
propshaft
@@ -1090,7 +1090,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 3.4.1p0
ruby 3.4.1p0
BUNDLED WITH
2.7.2
4.0.1

View File

@@ -57,27 +57,18 @@ class Api::V1::AnnualReportsController < Api::BaseController
render_empty
end
def refresh_key
"wrapstodon:#{current_account.id}:#{year}"
end
private
def report_state
return 'available' if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year)
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
AnnualReport.new(current_account, year).state do |async_refresh|
add_async_refresh_header(async_refresh, retry_seconds: 2)
'generating'
elsif AnnualReport.current_campaign == year && AnnualReport.new(current_account, year).eligible?
'eligible'
else
'ineligible'
end
end
def refresh_key
"wrapstodon:#{current_account.id}:#{year}"
end
def year
params[:id]&.to_i
end

View File

@@ -10,6 +10,7 @@ module WrapstodonHelper
).as_json
payload[:me] = current_account.id.to_s if user_signed_in?
payload[:domain] = Addressable::IDNA.to_unicode(Rails.configuration.x.local_domain)
json_string = payload.to_json

View File

@@ -25,7 +25,7 @@ function loaded() {
const initialState = JSON.parse(
propsNode.textContent,
) as ApiAnnualReportResponse & { me?: string };
) as ApiAnnualReportResponse & { me?: string; domain: string };
const report = initialState.annual_reports[0];
if (!report) {
@@ -38,6 +38,7 @@ function loaded() {
meta: {
locale: document.documentElement.lang,
me: initialState.me,
domain: initialState.domain,
},
accounts: initialState.accounts,
}),

View File

@@ -1,5 +1,3 @@
import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report';
import api from '../api';
import { importFetchedAccount } from './importer';
@@ -31,9 +29,6 @@ export const fetchServer = () => (dispatch, getState) => {
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data));
if (data.wrapstodon) {
void dispatch(checkAnnualReport());
}
}).catch(err => dispatch(fetchServerFail(err)));
};

View File

@@ -4,20 +4,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
type EmojiProps = ComponentProps<typeof Emoji> & {
style: 'auto' | 'native' | 'twemoji';
};
const meta = {
title: 'Components/Emoji',
component: Emoji,
args: {
code: '🖤',
state: 'auto',
style: 'auto',
},
argTypes: {
code: {
name: 'Emoji',
},
state: {
style: {
control: {
type: 'select',
labels: {
@@ -28,11 +30,7 @@ const meta = {
},
options: ['auto', 'native', 'twemoji'],
name: 'Emoji Style',
mapping: {
auto: { meta: { emoji_style: 'auto' } },
native: { meta: { emoji_style: 'native' } },
twemoji: { meta: { emoji_style: 'twemoji' } },
},
reduxPath: 'meta.emoji_style',
},
},
render(args) {

View File

@@ -25,7 +25,7 @@ function loaded() {
const initialState = JSON.parse(
propsNode.textContent,
) as ApiAnnualReportResponse & { me?: string };
) as ApiAnnualReportResponse & { me?: string; domain: string };
const report = initialState.annual_reports[0];
if (!report) {
@@ -38,6 +38,7 @@ function loaded() {
meta: {
locale: document.documentElement.lang,
me: initialState.me,
domain: initialState.domain,
},
accounts: initialState.accounts,
}),

View File

@@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
import { closeModal } from '@/flavours/glitch/actions/modal';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { getReport } from '@/flavours/glitch/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
@@ -26,7 +27,7 @@ import { NewPosts } from './new_posts';
const moduleClassNames = classNames.bind(styles);
const accountSelector = createAppSelector(
export const accountSelector = createAppSelector(
[(state) => state.accounts, (state) => state.annualReport.report],
(accounts, report) => {
if (report?.schema_version === 2) {
@@ -43,6 +44,13 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector(accountSelector);
const needsReport = !report; // Make into boolean to avoid object comparison in deps.
useEffect(() => {
if (needsReport) {
void dispatch(getReport());
}
}, [dispatch, needsReport]);
const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
@@ -57,7 +65,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
}
}, [pathname, initialPathname, close]);
if (!report) {
if (needsReport) {
return <LoadingIndicator />;
}

View File

@@ -4,10 +4,7 @@ import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { closeModal } from '@/flavours/glitch/actions/modal';
import {
generateReport,
selectWrapstodonYear,
} from '@/flavours/glitch/reducers/slices/annual_report';
import { generateReport } from '@/flavours/glitch/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AnnualReport } from '.';
@@ -21,8 +18,7 @@ const AnnualReportModal: React.FC<{
onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]);
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const showAnnouncement = year && state && state !== 'available';

View File

@@ -7,7 +7,6 @@ import classNames from 'classnames';
import { openModal } from '@/flavours/glitch/actions/modal';
import { Icon } from '@/flavours/glitch/components/icon';
import { selectWrapstodonYear } from '@/flavours/glitch/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
@@ -23,8 +22,7 @@ const selectReportModalOpen = createAppSelector(
);
export const AnnualReportNavItem: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const active = useAppSelector(selectReportModalOpen);
const dispatch = useAppDispatch();

View File

@@ -16,22 +16,16 @@ $mobile-breakpoint: 540px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
gap: 1.8rem;
margin-top: 2rem;
font-size: 16px;
line-height: 1.4;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
strong {
font-weight: 600;
}
a:any-link {
color: inherit;
@@ -43,3 +37,22 @@ $mobile-breakpoint: 540px;
color: var(--color-text-primary);
}
}
.logo {
width: 2rem;
opacity: 0.6;
}
.footerSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.linkList {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -2,43 +2,66 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { IconLogo } from '@/flavours/glitch/components/logo';
import { useAppSelector } from '@/flavours/glitch/store';
import { AnnualReport } from './index';
import { AnnualReport, accountSelector } from './index';
import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => {
const isLoggedIn = useAppSelector((state) => !!state.meta.get('me'));
const account = useAppSelector(accountSelector);
const domain = useAppSelector((state) => state.meta.get('domain') as string);
return (
<main className={classes.wrapper}>
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
/>
<nav className={classes.nav}>
<a href='https://joinmastodon.org'>
<FormattedMessage id='footer.about' defaultMessage='About' />
</a>
{!isLoggedIn && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<div className={classes.footerSection}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
tagName='p'
/>
<ul className={classes.linkList}>
<li>
<a href='https://joinmastodon.org'>
<FormattedMessage
id='footer.about_mastodon'
defaultMessage='About Mastodon'
/>
</a>
</li>
<li>
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</li>
</ul>
</div>
<div className={classes.footerSection}>
<FormattedMessage
id='annual_report.shared_page.footer_server_info'
defaultMessage='{username} uses {domain}, one of many communities powered by Mastodon.'
values={{
username: <DisplayName variant='simple' account={account} />,
domain: <strong>{domain}</strong>,
}}
tagName='p'
/>
<a href='/about'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
id='footer.about_server'
defaultMessage='About {domain}'
values={{ domain }}
/>
</a>
</nav>
</div>
</footer>
</main>
);

View File

@@ -3,17 +3,13 @@ import type { FC } from 'react';
import { openModal } from '@/flavours/glitch/actions/modal';
import { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
import {
generateReport,
selectWrapstodonYear,
} from '@/flavours/glitch/reducers/slices/annual_report';
import { generateReport } from '@/flavours/glitch/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { AnnualReportAnnouncement } from './announcement';
export const AnnualReportTimeline: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const dispatch = useAppDispatch();
const handleBuildRequest = useCallback(() => {

View File

@@ -4,6 +4,7 @@ import type { List } from 'immutable';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import type { CustomEmoji } from '@/flavours/glitch/models/custom_emoji';
import type { Status } from '@/flavours/glitch/models/status';
import type { Mention } from './embedded_status';
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
className={className}
lang={status.get('language') as string}
htmlString={status.get('contentHtml') as string}
extraEmojis={status.get('emoji') as List<CustomEmoji>}
/>
);
};

View File

@@ -24,6 +24,7 @@ import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache';
@@ -411,6 +412,7 @@ class UI extends PureComponent {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages());
this.props.dispatch(checkAnnualReport());
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
}

View File

@@ -1,3 +1,4 @@
import type { ApiAnnualReportState } from './api/annual_report';
import type { ApiAccountJSON } from './api_types/accounts';
type InitialStateLanguage = [code: string, name: string, localName: string];
@@ -49,6 +50,7 @@ interface InitialStateMeta {
status_page_url: string;
terms_of_service_enabled: boolean;
emoji_style?: string;
wrapstodon?: InitialWrapstodonState | null;
default_content_type: string;
}
@@ -67,6 +69,11 @@ interface PollLimits {
max_expiration: number;
}
interface InitialWrapstodonState {
year: number;
state: ApiAnnualReportState;
}
export interface InitialState {
accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[];
@@ -155,6 +162,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
export const wrapstodon = getMeta('wrapstodon');
const displayNames =
// Intl.DisplayNames can be undefined in old browsers

View File

@@ -11,22 +11,25 @@ import {
apiGetAnnualReportState,
apiRequestGenerateAnnualReport,
} from '@/flavours/glitch/api/annual_report';
import { wrapstodon } from '@/flavours/glitch/initial_state';
import type { AnnualReport } from '@/flavours/glitch/models/annual_report';
import {
createAppSelector,
createAppThunk,
createDataLoadingThunk,
} from '../../store/typed_functions';
} from '@/flavours/glitch/store/typed_functions';
interface AnnualReportState {
year?: number;
state?: ApiAnnualReportState;
report?: AnnualReport;
}
const annualReportSlice = createSlice({
name: 'annualReport',
initialState: {} as AnnualReportState,
initialState: {
year: wrapstodon?.year,
state: wrapstodon?.state,
} as AnnualReportState,
reducers: {
setReport(state, action: PayloadAction<AnnualReport>) {
state.report = action.payload;
@@ -52,18 +55,17 @@ const annualReportSlice = createSlice({
export const annualReport = annualReportSlice.reducer;
export const { setReport } = annualReportSlice.actions;
export const selectWrapstodonYear = createAppSelector(
[(state) => state.server.getIn(['server', 'wrapstodon'])],
(year: unknown) => (typeof year === 'number' && year > 2000 ? year : null),
);
// This kicks everything off, and is called after fetching the server info.
// Called on initial load to check if we need to refresh the report state.
export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`,
(_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState());
const { state, year } = getState().annualReport;
const me = getState().meta.get('me') as string;
if (!year || !me) {
// If we have a state, we only need to fetch it again to poll for changes.
const needsStateRefresh = !state || state === 'generating';
if (!year || !me || !needsStateRefresh) {
return;
}
void dispatch(fetchReportState());
@@ -73,7 +75,7 @@ export const checkAnnualReport = createAppThunk(
const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}
@@ -84,8 +86,6 @@ const fetchReportState = createDataLoadingThunk(
window.setTimeout(() => {
void dispatch(fetchReportState());
}, 1_000 * refresh.retry);
} else if (state === 'available') {
void dispatch(getReport());
}
return state;
@@ -97,7 +97,7 @@ const fetchReportState = createDataLoadingThunk(
export const generateReport = createDataLoadingThunk(
`${annualReportSlice.name}/generateReport`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}
@@ -111,7 +111,7 @@ export const generateReport = createDataLoadingThunk(
export const getReport = createDataLoadingThunk(
`${annualReportSlice.name}/getReport`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}

View File

@@ -2913,7 +2913,7 @@ a.account__display-name {
cursor: default;
&:focus {
color: rgb(from var(--color-text-disabled) r g b / 70%);
color: var(--color-text-on-disabled);
background: var(--color-bg-disabled);
outline: 0;
}
@@ -4059,8 +4059,8 @@ a.account__display-name {
box-sizing: border-box;
&:hover,
&:focus,
&:active {
&:active,
&:focus-visible {
color: var(--color-text-primary);
}
@@ -4078,14 +4078,7 @@ a.account__display-name {
}
&--logo {
background: transparent;
padding: 10px;
&:hover,
&:focus,
&:active {
background: transparent;
}
}
}

View File

@@ -1,5 +1,3 @@
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import api from '../api';
import { importFetchedAccount } from './importer';
@@ -31,9 +29,6 @@ export const fetchServer = () => (dispatch, getState) => {
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data));
if (data.wrapstodon) {
void dispatch(checkAnnualReport());
}
}).catch(err => dispatch(fetchServerFail(err)));
};

View File

@@ -1,16 +1,28 @@
import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
import { Account } from './index';
type Props = Omit<ComponentProps<typeof Account>, 'id'> & {
name: string;
username: string;
};
const meta = {
title: 'Components/Account',
component: Account,
argTypes: {
id: {
name: {
type: 'string',
description: 'ID of the account to display',
description: 'The display name of the account',
reduxPath: 'accounts.1.display_name_html',
},
username: {
type: 'string',
description: 'The username of the account',
reduxPath: 'accounts.1.acct',
},
size: {
type: 'number',
@@ -40,7 +52,8 @@ const meta = {
},
},
args: {
id: '1',
name: 'Test User',
username: 'testuser',
size: 46,
hidden: false,
minimal: false,
@@ -55,17 +68,16 @@ const meta = {
},
},
},
} satisfies Meta<typeof Account>;
render(args) {
return <Account id='1' {...args} />;
},
} satisfies Meta<Props>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
id: '1',
},
};
export const Primary: Story = {};
export const Hidden: Story = {
args: {

View File

@@ -4,20 +4,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
type EmojiProps = ComponentProps<typeof Emoji> & {
style: 'auto' | 'native' | 'twemoji';
};
const meta = {
title: 'Components/Emoji',
component: Emoji,
args: {
code: '🖤',
state: 'auto',
style: 'auto',
},
argTypes: {
code: {
name: 'Emoji',
},
state: {
style: {
control: {
type: 'select',
labels: {
@@ -28,11 +30,7 @@ const meta = {
},
options: ['auto', 'native', 'twemoji'],
name: 'Emoji Style',
mapping: {
auto: { meta: { emoji_style: 'auto' } },
native: { meta: { emoji_style: 'native' } },
twemoji: { meta: { emoji_style: 'twemoji' } },
},
reduxPath: 'meta.emoji_style',
},
},
render(args) {

View File

@@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
import { closeModal } from '@/mastodon/actions/modal';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { getReport } from '@/mastodon/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
@@ -26,7 +27,7 @@ import { NewPosts } from './new_posts';
const moduleClassNames = classNames.bind(styles);
const accountSelector = createAppSelector(
export const accountSelector = createAppSelector(
[(state) => state.accounts, (state) => state.annualReport.report],
(accounts, report) => {
if (report?.schema_version === 2) {
@@ -43,6 +44,13 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
const dispatch = useAppDispatch();
const report = useAppSelector((state) => state.annualReport.report);
const account = useAppSelector(accountSelector);
const needsReport = !report; // Make into boolean to avoid object comparison in deps.
useEffect(() => {
if (needsReport) {
void dispatch(getReport());
}
}, [dispatch, needsReport]);
const close = useCallback(() => {
dispatch(closeModal({ modalType: 'ANNUAL_REPORT', ignoreFocus: false }));
@@ -57,7 +65,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
}
}, [pathname, initialPathname, close]);
if (!report) {
if (needsReport) {
return <LoadingIndicator />;
}

View File

@@ -4,10 +4,7 @@ import { useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { closeModal } from '@/mastodon/actions/modal';
import {
generateReport,
selectWrapstodonYear,
} from '@/mastodon/reducers/slices/annual_report';
import { generateReport } from '@/mastodon/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AnnualReport } from '.';
@@ -21,8 +18,7 @@ const AnnualReportModal: React.FC<{
onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]);
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const showAnnouncement = year && state && state !== 'available';

View File

@@ -8,7 +8,6 @@ import classNames from 'classnames';
import IconPlanet from '@/images/icons/icon_planet.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { Icon } from '@/mastodon/components/icon';
import { selectWrapstodonYear } from '@/mastodon/reducers/slices/annual_report';
import {
createAppSelector,
useAppDispatch,
@@ -23,8 +22,7 @@ const selectReportModalOpen = createAppSelector(
);
export const AnnualReportNavItem: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const active = useAppSelector(selectReportModalOpen);
const dispatch = useAppDispatch();

View File

@@ -16,22 +16,16 @@ $mobile-breakpoint: 540px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
gap: 1.8rem;
margin-top: 2rem;
font-size: 16px;
line-height: 1.4;
text-align: center;
color: var(--color-text-secondary);
}
.logo {
width: 2rem;
opacity: 0.6;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
strong {
font-weight: 600;
}
a:any-link {
color: inherit;
@@ -43,3 +37,22 @@ $mobile-breakpoint: 540px;
color: var(--color-text-primary);
}
}
.logo {
width: 2rem;
opacity: 0.6;
}
.footerSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.linkList {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -2,43 +2,66 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/mastodon/components/display_name';
import { IconLogo } from '@/mastodon/components/logo';
import { useAppSelector } from '@/mastodon/store';
import { AnnualReport } from './index';
import { AnnualReport, accountSelector } from './index';
import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => {
const isLoggedIn = useAppSelector((state) => !!state.meta.get('me'));
const account = useAppSelector(accountSelector);
const domain = useAppSelector((state) => state.meta.get('domain') as string);
return (
<main className={classes.wrapper}>
<AnnualReport />
<footer className={classes.footer}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
/>
<nav className={classes.nav}>
<a href='https://joinmastodon.org'>
<FormattedMessage id='footer.about' defaultMessage='About' />
</a>
{!isLoggedIn && (
<a href='https://joinmastodon.org/servers'>
<FormattedMessage
id='annual_report.shared_page.sign_up'
defaultMessage='Sign up'
/>
</a>
)}
<a href='https://joinmastodon.org/sponsors'>
<div className={classes.footerSection}>
<IconLogo className={classes.logo} />
<FormattedMessage
id='annual_report.shared_page.footer'
defaultMessage='Generated with {heart} by the Mastodon team'
values={{ heart: '🐘' }}
tagName='p'
/>
<ul className={classes.linkList}>
<li>
<a href='https://joinmastodon.org'>
<FormattedMessage
id='footer.about_mastodon'
defaultMessage='About Mastodon'
/>
</a>
</li>
<li>
<a href='https://joinmastodon.org/sponsors'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
/>
</a>
</li>
</ul>
</div>
<div className={classes.footerSection}>
<FormattedMessage
id='annual_report.shared_page.footer_server_info'
defaultMessage='{username} uses {domain}, one of many communities powered by Mastodon.'
values={{
username: <DisplayName variant='simple' account={account} />,
domain: <strong>{domain}</strong>,
}}
tagName='p'
/>
<a href='/about'>
<FormattedMessage
id='annual_report.shared_page.donate'
defaultMessage='Donate'
id='footer.about_server'
defaultMessage='About {domain}'
values={{ domain }}
/>
</a>
</nav>
</div>
</footer>
</main>
);

View File

@@ -3,17 +3,13 @@ import type { FC } from 'react';
import { openModal } from '@/mastodon/actions/modal';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import {
generateReport,
selectWrapstodonYear,
} from '@/mastodon/reducers/slices/annual_report';
import { generateReport } from '@/mastodon/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AnnualReportAnnouncement } from './announcement';
export const AnnualReportTimeline: FC = () => {
const { state } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const { state, year } = useAppSelector((state) => state.annualReport);
const dispatch = useAppDispatch();
const handleBuildRequest = useCallback(() => {

View File

@@ -4,6 +4,7 @@ import type { List } from 'immutable';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { Status } from '@/mastodon/models/status';
import type { Mention } from './embedded_status';
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
className={className}
lang={status.get('language') as string}
htmlString={status.get('contentHtml') as string}
extraEmojis={status.get('emoji') as List<CustomEmoji>}
/>
);
};

View File

@@ -21,6 +21,7 @@ import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache';
@@ -396,6 +397,7 @@ class UI extends PureComponent {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages());
this.props.dispatch(checkAnnualReport());
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
}

View File

@@ -1,3 +1,4 @@
import type { ApiAnnualReportState } from './api/annual_report';
import type { ApiAccountJSON } from './api_types/accounts';
type InitialStateLanguage = [code: string, name: string, localName: string];
@@ -47,6 +48,7 @@ interface InitialStateMeta {
status_page_url: string;
terms_of_service_enabled: boolean;
emoji_style?: string;
wrapstodon?: InitialWrapstodonState | null;
}
interface Role {
@@ -57,6 +59,11 @@ interface Role {
highlighted: boolean;
}
interface InitialWrapstodonState {
year: number;
state: ApiAnnualReportState;
}
export interface InitialState {
accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[];
@@ -128,6 +135,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
export const wrapstodon = getMeta('wrapstodon');
const displayNames =
// Intl.DisplayNames can be undefined in old browsers

View File

@@ -121,7 +121,7 @@
"annual_report.nav_item.badge": "New",
"annual_report.shared_page.donate": "Donate",
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team",
"annual_report.shared_page.sign_up": "Sign up",
"annual_report.shared_page.footer_server_info": "{username} uses {domain}, one of many communities powered by Mastodon.",
"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",
@@ -441,6 +441,8 @@
"follow_suggestions.who_to_follow": "Who to follow",
"followed_tags": "Followed hashtags",
"footer.about": "About",
"footer.about_mastodon": "About Mastodon",
"footer.about_server": "About {domain}",
"footer.about_this_server": "About",
"footer.directory": "Profiles directory",
"footer.get_app": "Get the app",

View File

@@ -11,22 +11,25 @@ import {
apiGetAnnualReportState,
apiRequestGenerateAnnualReport,
} from '@/mastodon/api/annual_report';
import { wrapstodon } from '@/mastodon/initial_state';
import type { AnnualReport } from '@/mastodon/models/annual_report';
import {
createAppSelector,
createAppThunk,
createDataLoadingThunk,
} from '../../store/typed_functions';
} from '@/mastodon/store/typed_functions';
interface AnnualReportState {
year?: number;
state?: ApiAnnualReportState;
report?: AnnualReport;
}
const annualReportSlice = createSlice({
name: 'annualReport',
initialState: {} as AnnualReportState,
initialState: {
year: wrapstodon?.year,
state: wrapstodon?.state,
} as AnnualReportState,
reducers: {
setReport(state, action: PayloadAction<AnnualReport>) {
state.report = action.payload;
@@ -52,18 +55,17 @@ const annualReportSlice = createSlice({
export const annualReport = annualReportSlice.reducer;
export const { setReport } = annualReportSlice.actions;
export const selectWrapstodonYear = createAppSelector(
[(state) => state.server.getIn(['server', 'wrapstodon'])],
(year: unknown) => (typeof year === 'number' && year > 2000 ? year : null),
);
// This kicks everything off, and is called after fetching the server info.
// Called on initial load to check if we need to refresh the report state.
export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`,
(_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState());
const { state, year } = getState().annualReport;
const me = getState().meta.get('me') as string;
if (!year || !me) {
// If we have a state, we only need to fetch it again to poll for changes.
const needsStateRefresh = !state || state === 'generating';
if (!year || !me || !needsStateRefresh) {
return;
}
void dispatch(fetchReportState());
@@ -73,7 +75,7 @@ export const checkAnnualReport = createAppThunk(
const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}
@@ -84,8 +86,6 @@ const fetchReportState = createDataLoadingThunk(
window.setTimeout(() => {
void dispatch(fetchReportState());
}, 1_000 * refresh.retry);
} else if (state === 'available') {
void dispatch(getReport());
}
return state;
@@ -97,7 +97,7 @@ const fetchReportState = createDataLoadingThunk(
export const generateReport = createDataLoadingThunk(
`${annualReportSlice.name}/generateReport`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}
@@ -111,7 +111,7 @@ export const generateReport = createDataLoadingThunk(
export const getReport = createDataLoadingThunk(
`${annualReportSlice.name}/getReport`,
async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState());
const { year } = getState().annualReport;
if (!year) {
throw new Error('Year is not set');
}

View File

@@ -2848,7 +2848,7 @@ a.account__display-name {
cursor: default;
&:focus {
color: rgb(from var(--color-text-disabled) r g b / 70%);
color: var(--color-text-on-disabled);
background: var(--color-bg-disabled);
outline: 0;
}
@@ -3994,8 +3994,8 @@ a.account__display-name {
box-sizing: border-box;
&:hover,
&:focus,
&:active {
&:active,
&:focus-visible {
color: var(--color-text-primary);
}
@@ -4013,14 +4013,7 @@ a.account__display-name {
}
&--logo {
background: transparent;
padding: 10px;
&:hover,
&:focus,
&:active {
background: transparent;
}
}
}

View File

@@ -34,6 +34,25 @@ class AnnualReport
end
end
def state
return 'available' if GeneratedAnnualReport.exists?(account_id: @account.id, year: @year)
async_refresh = AsyncRefresh.new(refresh_key)
if async_refresh.running?
yield async_refresh if block_given?
'generating'
elsif AnnualReport.current_campaign == @year && eligible?
'eligible'
else
'ineligible'
end
end
def refresh_key
"wrapstodon:#{@account.id}:#{@year}"
end
def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year)

View File

@@ -33,7 +33,7 @@ class DomainAllow < ApplicationRecord
def rule_for(domain)
return if domain.blank?
uri = Addressable::URI.new.tap { |u| u.host = domain.delete('/') }
uri = Addressable::URI.new.tap { |u| u.host = domain.strip.delete('/') }
find_by(domain: uri.normalized_host)
end

View File

@@ -48,6 +48,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_content_type] = object_account_user.setting_default_content_type
store[:show_trends] = Setting.trends && object_account_user.setting_trends
store[:emoji_style] = object_account_user.settings['web.emoji_style']
store[:wrapstodon] = wrapstodon
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media
@@ -110,6 +111,16 @@ class InitialStateSerializer < ActiveModel::Serializer
private
def wrapstodon
current_campaign = AnnualReport.current_campaign
return if current_campaign.blank?
{
year: current_campaign,
state: AnnualReport.new(object.current_account, current_campaign).state,
}
end
def default_meta_store
{
access_token: object.token,

View File

@@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService
# Make sure we never mention blocked accounts
unless @current_mentions.empty?
mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains))
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains).pluck(:domain))
mentioned_account_ids = @current_mentions.map(&:account_id)
blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id))

View File

@@ -123,7 +123,7 @@
"vite-plugin-manifest-sri": "^0.2.0",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^5.1.4",
"vite-tsconfig-paths": "^6.0.0",
"wicg-inert": "^3.1.2",
"workbox-expiration": "^7.3.0",
"workbox-routing": "^7.3.0",
@@ -182,7 +182,7 @@
"lint-staged": "^16.2.6",
"msw": "^2.12.1",
"msw-storybook-addon": "^2.0.6",
"playwright": "^1.56.1",
"playwright": "^1.57.0",
"prettier": "^3.3.3",
"react-test-renderer": "^18.2.0",
"storybook": "^10.0.5",

View File

@@ -68,6 +68,26 @@ RSpec.describe FollowerAccountsController do
end
end
context 'when request is signed in and user blocks an account' do
let(:account) { Fabricate :account }
before do
Fabricate :block, account:, target_account: follower_bob
sign_in(account.user)
end
it 'returns followers without blocked' do
expect(response)
.to have_http_status(200)
expect(response.parsed_body)
.to include(
orderedItems: contain_exactly(
include(follow_from_chris.account.id.to_s)
)
)
end
end
context 'when account is permanently suspended' do
before do
alice.suspend!

View File

@@ -68,6 +68,26 @@ RSpec.describe FollowingAccountsController do
end
end
context 'when request is signed in and user blocks an account' do
let(:account) { Fabricate :account }
before do
Fabricate :block, account:, target_account: followee_bob
sign_in(account.user)
end
it 'returns followers without blocked' do
expect(response)
.to have_http_status(200)
expect(response.parsed_body)
.to include(
orderedItems: contain_exactly(
include(follow_of_chris.target_account.id.to_s)
)
)
end
end
context 'when account is permanently suspended' do
before do
alice.suspend!

View File

@@ -17,7 +17,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do
end
context 'with account' do
let(:account) { Fabricate(:account) }
let(:account) { Fabricate.build(:account, id: 123) }
it 'returns a labeled avatar link to the account' do
expect(parsed_html.a[:href]).to eq admin_account_path(account.id)
@@ -39,7 +39,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do
end
context 'with account' do
let(:account) { Fabricate(:account) }
let(:account) { Fabricate.build(:account, id: 123) }
it 'returns an inline link to the account' do
expect(parsed_html.a[:href]).to eq admin_account_path(account.id)

View File

@@ -4,8 +4,9 @@ require 'rails_helper'
RSpec.describe Admin::DashboardHelper do
describe 'relevant_account_timestamp' do
let(:account) { Fabricate(:account) }
context 'with an account with older sign in' do
let(:account) { Fabricate(:account) }
let(:stamp) { 10.days.ago }
it 'returns a time element' do
@@ -18,8 +19,6 @@ RSpec.describe Admin::DashboardHelper do
end
context 'with an account with newer sign in' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do
account.user.update(current_sign_in_at: 10.hours.ago)
result = helper.relevant_account_timestamp(account)
@@ -29,8 +28,6 @@ RSpec.describe Admin::DashboardHelper do
end
context 'with an account where the user is pending' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do
account.user.update(current_sign_in_at: nil)
account.user.update(approved: false)
@@ -42,7 +39,6 @@ RSpec.describe Admin::DashboardHelper do
end
context 'with an account with a last status value' do
let(:account) { Fabricate(:account) }
let(:stamp) { 5.minutes.ago }
it 'returns a time element' do
@@ -56,8 +52,6 @@ RSpec.describe Admin::DashboardHelper do
end
context 'with an account without sign in or last status or pending' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do
account.user.update(current_sign_in_at: nil)
result = helper.relevant_account_timestamp(account)

View File

@@ -54,7 +54,7 @@ RSpec.describe Admin::Trends::StatusesHelper do
context 'with a status that has emoji' do
before { Fabricate(:custom_emoji, shortcode: 'florpy') }
let(:status) { Fabricate(:status, text: 'hello there :florpy:') }
let(:status) { Fabricate.build(:status, text: 'hello there :florpy:') }
it 'renders a correct preview text' do
result = helper.one_line_preview(status)

View File

@@ -18,7 +18,7 @@ RSpec.describe FormattingHelper do
end
context 'with a spoiler and an emoji and a poll' do
let(:status) { Fabricate(:status, text: 'Hello :world: <>', spoiler_text: 'This is a spoiler<>', poll: Fabricate(:poll, options: %w(Yes<> No))) }
let(:status) { Fabricate(:status, text: 'Hello :world: <>', spoiler_text: 'This is a spoiler<>', poll: Fabricate.build(:poll, options: %w(Yes<> No))) }
before { Fabricate :custom_emoji, shortcode: 'world' }

View File

@@ -21,7 +21,7 @@ RSpec.describe HomeHelper do
end
context 'with a valid account' do
let(:account) { Fabricate(:account) }
let(:account) { Fabricate.build(:account) }
before { helper.extend controller_helpers }

View File

@@ -5,8 +5,10 @@ require 'rails_helper'
RSpec.describe MediaComponentHelper do
before { helper.extend controller_helpers }
let(:media) { Fabricate.build(:media_attachment, type:, status: Fabricate.build(:status)) }
describe 'render_video_component' do
let(:media) { Fabricate(:media_attachment, type: :video, status: Fabricate(:status)) }
let(:type) { :video }
let(:result) { helper.render_video_component(media.status) }
it 'renders a react component for the video' do
@@ -15,7 +17,7 @@ RSpec.describe MediaComponentHelper do
end
describe 'render_audio_component' do
let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) }
let(:type) { :audio }
let(:result) { helper.render_audio_component(media.status) }
it 'renders a react component for the audio' do
@@ -24,7 +26,7 @@ RSpec.describe MediaComponentHelper do
end
describe 'render_media_gallery_component' do
let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) }
let(:type) { :audio }
let(:result) { helper.render_media_gallery_component(media.status) }
it 'renders a react component for the media gallery' do

View File

@@ -24,16 +24,13 @@ RSpec.describe StatusesHelper do
end
describe '#media_summary' do
it 'describes the media on a status' do
status = Fabricate :status
Fabricate :media_attachment, status: status, type: :video
Fabricate :media_attachment, status: status, type: :audio
Fabricate :media_attachment, status: status, type: :image
subject { helper.media_summary(status) }
result = helper.media_summary(status)
let(:status) { Fabricate.build :status }
expect(result).to eq('Attached: 1 image · 1 video · 1 audio')
end
before { %i(video audio image).each { |type| Fabricate.build :media_attachment, status:, type: } }
it { is_expected.to eq('Attached: 1 image · 1 video · 1 audio') }
end
describe 'visibility_icon' do

View File

@@ -3,6 +3,37 @@
require 'rails_helper'
RSpec.describe Account::FinderConcern do
describe '.representative' do
context 'with an instance actor using an invalid legacy username' do
let(:legacy_value) { 'localhost:3000' }
before { Account.find(Account::INSTANCE_ACTOR_ID).update_attribute(:username, legacy_value) }
it 'updates the username to the new value' do
expect { Account.representative }
.to change { Account.find(Account::INSTANCE_ACTOR_ID).username }.from(legacy_value).to('mastodon.internal')
end
end
context 'without an instance actor' do
before { Account.find(Account::INSTANCE_ACTOR_ID).destroy! }
it 'creates an instance actor' do
expect { Account.representative }
.to change(Account.where(id: Account::INSTANCE_ACTOR_ID), :count).from(0).to(1)
end
end
context 'with a correctly loaded instance actor' do
let(:instance_actor) { Account.find(Account::INSTANCE_ACTOR_ID) }
it 'returns the instance actor record' do
expect(Account.representative)
.to eq(instance_actor)
end
end
end
describe 'local finders' do
let!(:account) { Fabricate(:account, username: 'Alice') }

View File

@@ -27,4 +27,27 @@ RSpec.describe DomainAllow do
it { is_expected.to contain_exactly(allowed_domain.domain, other_allowed_domain.domain) }
end
end
describe '.rule_for' do
subject { described_class.rule_for(domain) }
let(:domain) { 'host.example' }
context 'with no records' do
it { is_expected.to be_nil }
end
context 'with matching record' do
let!(:domain_allow) { Fabricate :domain_allow, domain: }
it { is_expected.to eq(domain_allow) }
end
context 'when called with non normalized string' do
let!(:domain_allow) { Fabricate :domain_allow, domain: }
let(:domain) { ' HOST.example/' }
it { is_expected.to eq(domain_allow) }
end
end
end

View File

@@ -35,6 +35,17 @@ RSpec.describe DomainBlock do
expect(described_class.rule_for('example.com')).to eq block
end
it 'returns most specific rule matching a blocked domain' do
_block = Fabricate(:domain_block, domain: 'example.com')
blog_block = Fabricate(:domain_block, domain: 'blog.example.com')
expect(described_class.rule_for('host.blog.example.com')).to eq blog_block
end
it 'returns rule matching a blocked domain when string needs normalization' do
block = Fabricate(:domain_block, domain: 'example.com')
expect(described_class.rule_for(' example.com/')).to eq block
end
it 'returns a rule matching a subdomain of a blocked domain' do
block = Fabricate(:domain_block, domain: 'example.com')
expect(described_class.rule_for('sub.example.com')).to eq block

View File

@@ -4,6 +4,8 @@ require 'rails_helper'
RSpec.describe EmailDomainBlock do
describe 'block?' do
subject { described_class.block?(input) }
let(:input) { nil }
context 'when given an e-mail address' do
@@ -14,12 +16,12 @@ RSpec.describe EmailDomainBlock do
it 'returns true if the domain is blocked' do
Fabricate(:email_domain_block, domain: 'example.com')
expect(described_class.block?(input)).to be true
expect(subject).to be true
end
it 'returns false if the domain is not blocked' do
Fabricate(:email_domain_block, domain: 'other-example.com')
expect(described_class.block?(input)).to be false
expect(subject).to be false
end
end
@@ -28,7 +30,7 @@ RSpec.describe EmailDomainBlock do
it 'returns true if it is a subdomain of a blocked domain' do
Fabricate(:email_domain_block, domain: 'example.com')
expect(described_class.block?(input)).to be true
expect(subject).to be true
end
end
end
@@ -38,8 +40,40 @@ RSpec.describe EmailDomainBlock do
it 'returns true if the domain is blocked' do
Fabricate(:email_domain_block, domain: 'mail.foo.com')
expect(described_class.block?(input)).to be true
expect(subject).to be true
end
end
context 'when given nil' do
it { is_expected.to be false }
end
context 'when given empty string' do
let(:input) { '' }
it { is_expected.to be true }
end
end
describe '.requires_approval?' do
subject { described_class.requires_approval?(input) }
let(:input) { nil }
context 'with a matching block requiring approval' do
before { Fabricate :email_domain_block, domain: input, allow_with_approval: true }
let(:input) { 'host.example' }
it { is_expected.to be true }
end
context 'with a matching block not requiring approval' do
before { Fabricate :email_domain_block, domain: input, allow_with_approval: false }
let(:input) { 'host.example' }
it { is_expected.to be false }
end
end
end

View File

@@ -25,4 +25,36 @@ RSpec.describe PreviewCardProvider do
end
end
end
describe '.matching_domain' do
subject { described_class.matching_domain(domain) }
let(:domain) { 'host.example' }
context 'without matching domains' do
it { is_expected.to be_nil }
end
context 'with exact matching domain' do
let!(:preview_card_provider) { Fabricate :preview_card_provider, domain: 'host.example' }
it { is_expected.to eq(preview_card_provider) }
end
context 'with matching domain segment' do
let!(:preview_card_provider) { Fabricate :preview_card_provider, domain: 'host.example' }
let(:domain) { 'www.blog.host.example' }
it { is_expected.to eq(preview_card_provider) }
end
context 'with multiple matching records' do
let!(:preview_card_provider_more) { Fabricate :preview_card_provider, domain: 'blog.host.example' }
let(:domain) { 'www.blog.host.example' }
before { Fabricate :preview_card_provider, domain: 'host.example' }
it { is_expected.to eq(preview_card_provider_more) }
end
end
end

View File

@@ -39,6 +39,15 @@ RSpec.describe User do
end
it { is_expected.to allow_value('admin@localhost').for(:email) }
context 'when registration form time is present' do
subject { Fabricate.build :user }
before { stub_const 'RegistrationFormTimeValidator::REGISTRATION_FORM_MIN_TIME', 3.seconds }
it { is_expected.to allow_value(10.seconds.ago).for(:registration_form_time) }
it { is_expected.to_not allow_value(1.second.ago).for(:registration_form_time).against(:base) }
end
end
describe 'Normalizations' do

View File

@@ -96,6 +96,28 @@ RSpec.describe '/api/v1/accounts' do
end
end
context 'when missing username value' do
subject do
post '/api/v1/accounts', headers: headers, params: { password: '12345678', email: 'hello@world.tld', agreement: 'true' }
end
it 'returns http unprocessable entity with username error message' do
expect { subject }
.to not_change(User, :count)
.and not_change(Account, :count)
expect(response)
.to have_http_status(422)
expect(response.media_type)
.to eq('application/json')
expect(response.parsed_body)
.to include(
error: /Validation failed/,
details: include(username: contain_exactly(include(error: 'ERR_BLANK', description: /can't be blank/)))
)
end
end
context 'when age verification is enabled' do
before do
Setting.min_age = 16

View File

@@ -8,9 +8,9 @@ RSpec.describe ProcessMentionsService do
let(:account) { Fabricate(:account, username: 'alice') }
context 'when mentions contain blocked accounts' do
let(:non_blocked_account) { Fabricate(:account) }
let(:individually_blocked_account) { Fabricate(:account) }
let(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com') }
let!(:non_blocked_account) { Fabricate(:account) }
let!(:individually_blocked_account) { Fabricate(:account) }
let!(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com', protocol: :activitypub) }
let(:status) { Fabricate(:status, account: account, text: "Hello @#{non_blocked_account.acct} @#{individually_blocked_account.acct} @#{domain_blocked_account.acct}", visibility: :public) }
before do

View File

@@ -2840,7 +2840,7 @@ __metadata:
msw: "npm:^2.12.1"
msw-storybook-addon: "npm:^2.0.6"
path-complete-extname: "npm:^1.0.0"
playwright: "npm:^1.56.1"
playwright: "npm:^1.57.0"
postcss-preset-env: "npm:^10.1.5"
prettier: "npm:^3.3.3"
prop-types: "npm:^15.8.1"
@@ -2886,7 +2886,7 @@ __metadata:
vite-plugin-manifest-sri: "npm:^0.2.0"
vite-plugin-pwa: "npm:^1.0.2"
vite-plugin-svgr: "npm:^4.3.0"
vite-tsconfig-paths: "npm:^5.1.4"
vite-tsconfig-paths: "npm:^6.0.0"
vitest: "npm:^4.0.5"
wicg-inert: "npm:^3.1.2"
workbox-expiration: "npm:^7.3.0"
@@ -10558,27 +10558,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.56.1":
version: 1.56.1
resolution: "playwright-core@npm:1.56.1"
"playwright-core@npm:1.57.0":
version: 1.57.0
resolution: "playwright-core@npm:1.57.0"
bin:
playwright-core: cli.js
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
languageName: node
linkType: hard
"playwright@npm:^1.56.1":
version: 1.56.1
resolution: "playwright@npm:1.56.1"
"playwright@npm:^1.57.0":
version: 1.57.0
resolution: "playwright@npm:1.57.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.56.1"
playwright-core: "npm:1.57.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
languageName: node
linkType: hard
@@ -14084,9 +14084,9 @@ __metadata:
languageName: node
linkType: hard
"vite-tsconfig-paths@npm:^5.1.4":
version: 5.1.4
resolution: "vite-tsconfig-paths@npm:5.1.4"
"vite-tsconfig-paths@npm:^6.0.0":
version: 6.0.1
resolution: "vite-tsconfig-paths@npm:6.0.1"
dependencies:
debug: "npm:^4.1.1"
globrex: "npm:^0.1.2"
@@ -14096,7 +14096,7 @@ __metadata:
peerDependenciesMeta:
vite:
optional: true
checksum: 10c0/6228f23155ea25d92b1e1702284cf8dc52ad3c683c5ca691edd5a4c82d2913e7326d00708cef1cbfde9bb226261df0e0a12e03ef1d43b6a92d8f02b483ef37e3
checksum: 10c0/c0702f1d2b9d2e3e6ebb44d8e9c27b17b1102e86946ab54b6bbd290419b134e84df4e451b55db973bc97d9de5689df6f67e479633df20244aa0c62ffd0b16e43
languageName: node
linkType: hard