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', locale: 'en',
}, },
decorators: [ decorators: [
(Story, { parameters, globals, args }) => { (Story, { parameters, globals, args, argTypes }) => {
// Get the locale from the global toolbar // Get the locale from the global toolbar
// and merge it with any parameters or args state. // and merge it with any parameters or args state.
const { locale } = globals as { locale: string }; const { locale } = globals as { locale: string };
const { state = {} } = parameters; 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( const reducer = reducerWithInitialState(
{ {
@@ -69,7 +88,7 @@ const preview: Preview = {
}, },
}, },
state as Record<string, unknown>, state as Record<string, unknown>,
argsState as Record<string, unknown>, argsState,
); );
const store = configureStore({ const store = configureStore({

View File

@@ -1,7 +1,20 @@
// The addon package.json incorrectly exports types, so we need to override them here. // 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 // See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
declare module '@storybook/addon-vitest/vitest-plugin' { declare module '@storybook/addon-vitest/vitest-plugin' {
export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; 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 {}; export {};

View File

@@ -138,7 +138,7 @@ group :test do
# Browser integration testing # Browser integration testing
gem 'capybara', '~> 3.39' gem 'capybara', '~> 3.39'
gem 'capybara-playwright-driver' 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 # Used to reset the database between system tests
gem 'database_cleaner-active_record' gem 'database_cleaner-active_record'

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ module WrapstodonHelper
).as_json ).as_json
payload[:me] = current_account.id.to_s if user_signed_in? 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 json_string = payload.to_json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,22 +16,16 @@ $mobile-breakpoint: 540px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 1.8rem;
margin-top: 2rem; margin-top: 2rem;
font-size: 16px; font-size: 16px;
line-height: 1.4;
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
}
.logo { strong {
width: 2rem; font-weight: 600;
opacity: 0.6; }
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link { a:any-link {
color: inherit; color: inherit;
@@ -43,3 +37,22 @@ $mobile-breakpoint: 540px;
color: var(--color-text-primary); 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 { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { IconLogo } from '@/flavours/glitch/components/logo'; import { IconLogo } from '@/flavours/glitch/components/logo';
import { useAppSelector } from '@/flavours/glitch/store'; import { useAppSelector } from '@/flavours/glitch/store';
import { AnnualReport } from './index'; import { AnnualReport, accountSelector } from './index';
import classes from './shared_page.module.scss'; import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => { 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 ( return (
<main className={classes.wrapper}> <main className={classes.wrapper}>
<AnnualReport /> <AnnualReport />
<footer className={classes.footer}> <footer className={classes.footer}>
<IconLogo className={classes.logo} /> <div className={classes.footerSection}>
<FormattedMessage <IconLogo className={classes.logo} />
id='annual_report.shared_page.footer' <FormattedMessage
defaultMessage='Generated with {heart} by the Mastodon team' id='annual_report.shared_page.footer'
values={{ heart: '🐘' }} defaultMessage='Generated with {heart} by the Mastodon team'
/> values={{ heart: '🐘' }}
<nav className={classes.nav}> tagName='p'
<a href='https://joinmastodon.org'> />
<FormattedMessage id='footer.about' defaultMessage='About' /> <ul className={classes.linkList}>
</a> <li>
{!isLoggedIn && ( <a href='https://joinmastodon.org'>
<a href='https://joinmastodon.org/servers'> <FormattedMessage
<FormattedMessage id='footer.about_mastodon'
id='annual_report.shared_page.sign_up' defaultMessage='About Mastodon'
defaultMessage='Sign up' />
/> </a>
</a> </li>
)} <li>
<a href='https://joinmastodon.org/sponsors'> <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 <FormattedMessage
id='annual_report.shared_page.donate' id='footer.about_server'
defaultMessage='Donate' defaultMessage='About {domain}'
values={{ domain }}
/> />
</a> </a>
</nav> </div>
</footer> </footer>
</main> </main>
); );

View File

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

View File

@@ -4,6 +4,7 @@ import type { List } from 'immutable';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; 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 { Status } from '@/flavours/glitch/models/status';
import type { Mention } from './embedded_status'; import type { Mention } from './embedded_status';
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
className={className} className={className}
lang={status.get('language') as string} lang={status.get('language') as string}
htmlString={status.get('contentHtml') 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 { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; 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 { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@@ -411,6 +412,7 @@ class UI extends PureComponent {
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(fetchNotifications()); this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages()); this.props.dispatch(fetchServerTranslationLanguages());
this.props.dispatch(checkAnnualReport());
setTimeout(() => this.props.dispatch(fetchServer()), 3000); 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'; import type { ApiAccountJSON } from './api_types/accounts';
type InitialStateLanguage = [code: string, name: string, localName: string]; type InitialStateLanguage = [code: string, name: string, localName: string];
@@ -49,6 +50,7 @@ interface InitialStateMeta {
status_page_url: string; status_page_url: string;
terms_of_service_enabled: boolean; terms_of_service_enabled: boolean;
emoji_style?: string; emoji_style?: string;
wrapstodon?: InitialWrapstodonState | null;
default_content_type: string; default_content_type: string;
} }
@@ -67,6 +69,11 @@ interface PollLimits {
max_expiration: number; max_expiration: number;
} }
interface InitialWrapstodonState {
year: number;
state: ApiAnnualReportState;
}
export interface InitialState { export interface InitialState {
accounts: Record<string, ApiAccountJSON>; accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[]; languages: InitialStateLanguage[];
@@ -155,6 +162,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled'); export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
export const wrapstodon = getMeta('wrapstodon');
const displayNames = const displayNames =
// Intl.DisplayNames can be undefined in old browsers // Intl.DisplayNames can be undefined in old browsers

View File

@@ -11,22 +11,25 @@ import {
apiGetAnnualReportState, apiGetAnnualReportState,
apiRequestGenerateAnnualReport, apiRequestGenerateAnnualReport,
} from '@/flavours/glitch/api/annual_report'; } from '@/flavours/glitch/api/annual_report';
import { wrapstodon } from '@/flavours/glitch/initial_state';
import type { AnnualReport } from '@/flavours/glitch/models/annual_report'; import type { AnnualReport } from '@/flavours/glitch/models/annual_report';
import { import {
createAppSelector,
createAppThunk, createAppThunk,
createDataLoadingThunk, createDataLoadingThunk,
} from '../../store/typed_functions'; } from '@/flavours/glitch/store/typed_functions';
interface AnnualReportState { interface AnnualReportState {
year?: number;
state?: ApiAnnualReportState; state?: ApiAnnualReportState;
report?: AnnualReport; report?: AnnualReport;
} }
const annualReportSlice = createSlice({ const annualReportSlice = createSlice({
name: 'annualReport', name: 'annualReport',
initialState: {} as AnnualReportState, initialState: {
year: wrapstodon?.year,
state: wrapstodon?.state,
} as AnnualReportState,
reducers: { reducers: {
setReport(state, action: PayloadAction<AnnualReport>) { setReport(state, action: PayloadAction<AnnualReport>) {
state.report = action.payload; state.report = action.payload;
@@ -52,18 +55,17 @@ const annualReportSlice = createSlice({
export const annualReport = annualReportSlice.reducer; export const annualReport = annualReportSlice.reducer;
export const { setReport } = annualReportSlice.actions; export const { setReport } = annualReportSlice.actions;
export const selectWrapstodonYear = createAppSelector( // Called on initial load to check if we need to refresh the report state.
[(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.
export const checkAnnualReport = createAppThunk( export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
(_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const { state, year } = getState().annualReport;
const me = getState().meta.get('me') as string; 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; return;
} }
void dispatch(fetchReportState()); void dispatch(fetchReportState());
@@ -73,7 +75,7 @@ export const checkAnnualReport = createAppThunk(
const fetchReportState = createDataLoadingThunk( const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`, `${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }
@@ -84,8 +86,6 @@ const fetchReportState = createDataLoadingThunk(
window.setTimeout(() => { window.setTimeout(() => {
void dispatch(fetchReportState()); void dispatch(fetchReportState());
}, 1_000 * refresh.retry); }, 1_000 * refresh.retry);
} else if (state === 'available') {
void dispatch(getReport());
} }
return state; return state;
@@ -97,7 +97,7 @@ const fetchReportState = createDataLoadingThunk(
export const generateReport = createDataLoadingThunk( export const generateReport = createDataLoadingThunk(
`${annualReportSlice.name}/generateReport`, `${annualReportSlice.name}/generateReport`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }
@@ -111,7 +111,7 @@ export const generateReport = createDataLoadingThunk(
export const getReport = createDataLoadingThunk( export const getReport = createDataLoadingThunk(
`${annualReportSlice.name}/getReport`, `${annualReportSlice.name}/getReport`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }

View File

@@ -2913,7 +2913,7 @@ a.account__display-name {
cursor: default; cursor: default;
&:focus { &:focus {
color: rgb(from var(--color-text-disabled) r g b / 70%); color: var(--color-text-on-disabled);
background: var(--color-bg-disabled); background: var(--color-bg-disabled);
outline: 0; outline: 0;
} }
@@ -4059,8 +4059,8 @@ a.account__display-name {
box-sizing: border-box; box-sizing: border-box;
&:hover, &:hover,
&:focus, &:active,
&:active { &:focus-visible {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@@ -4078,14 +4078,7 @@ a.account__display-name {
} }
&--logo { &--logo {
background: transparent;
padding: 10px; 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 api from '../api';
import { importFetchedAccount } from './importer'; import { importFetchedAccount } from './importer';
@@ -31,9 +29,6 @@ export const fetchServer = () => (dispatch, getState) => {
.get('/api/v2/instance').then(({ data }) => { .get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data)); dispatch(fetchServerSuccess(data));
if (data.wrapstodon) {
void dispatch(checkAnnualReport());
}
}).catch(err => dispatch(fetchServerFail(err))); }).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 type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState, relationshipsFactory } from '@/testing/factories'; import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
import { Account } from './index'; import { Account } from './index';
type Props = Omit<ComponentProps<typeof Account>, 'id'> & {
name: string;
username: string;
};
const meta = { const meta = {
title: 'Components/Account', title: 'Components/Account',
component: Account,
argTypes: { argTypes: {
id: { name: {
type: 'string', 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: { size: {
type: 'number', type: 'number',
@@ -40,7 +52,8 @@ const meta = {
}, },
}, },
args: { args: {
id: '1', name: 'Test User',
username: 'testuser',
size: 46, size: 46,
hidden: false, hidden: false,
minimal: 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; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Primary: Story = { export const Primary: Story = {};
args: {
id: '1',
},
};
export const Hidden: Story = { export const Hidden: Story = {
args: { args: {

View File

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

View File

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

View File

@@ -4,10 +4,7 @@ import { useCallback, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { closeModal } from '@/mastodon/actions/modal'; import { closeModal } from '@/mastodon/actions/modal';
import { import { generateReport } from '@/mastodon/reducers/slices/annual_report';
generateReport,
selectWrapstodonYear,
} from '@/mastodon/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AnnualReport } from '.'; import { AnnualReport } from '.';
@@ -21,8 +18,7 @@ const AnnualReportModal: React.FC<{
onChangeBackgroundColor('var(--color-bg-media-base)'); onChangeBackgroundColor('var(--color-bg-media-base)');
}, [onChangeBackgroundColor]); }, [onChangeBackgroundColor]);
const { state } = useAppSelector((state) => state.annualReport); const { state, year } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const showAnnouncement = year && state && state !== 'available'; 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 IconPlanet from '@/images/icons/icon_planet.svg?react';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { Icon } from '@/mastodon/components/icon'; import { Icon } from '@/mastodon/components/icon';
import { selectWrapstodonYear } from '@/mastodon/reducers/slices/annual_report';
import { import {
createAppSelector, createAppSelector,
useAppDispatch, useAppDispatch,
@@ -23,8 +22,7 @@ const selectReportModalOpen = createAppSelector(
); );
export const AnnualReportNavItem: FC = () => { export const AnnualReportNavItem: FC = () => {
const { state } = useAppSelector((state) => state.annualReport); const { state, year } = useAppSelector((state) => state.annualReport);
const year = useAppSelector(selectWrapstodonYear);
const active = useAppSelector(selectReportModalOpen); const active = useAppSelector(selectReportModalOpen);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@@ -16,22 +16,16 @@ $mobile-breakpoint: 540px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 1.8rem;
margin-top: 2rem; margin-top: 2rem;
font-size: 16px; font-size: 16px;
line-height: 1.4;
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
}
.logo { strong {
width: 2rem; font-weight: 600;
opacity: 0.6; }
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
a:any-link { a:any-link {
color: inherit; color: inherit;
@@ -43,3 +37,22 @@ $mobile-breakpoint: 540px;
color: var(--color-text-primary); 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 { FormattedMessage } from 'react-intl';
import { DisplayName } from '@/mastodon/components/display_name';
import { IconLogo } from '@/mastodon/components/logo'; import { IconLogo } from '@/mastodon/components/logo';
import { useAppSelector } from '@/mastodon/store'; import { useAppSelector } from '@/mastodon/store';
import { AnnualReport } from './index'; import { AnnualReport, accountSelector } from './index';
import classes from './shared_page.module.scss'; import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => { 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 ( return (
<main className={classes.wrapper}> <main className={classes.wrapper}>
<AnnualReport /> <AnnualReport />
<footer className={classes.footer}> <footer className={classes.footer}>
<IconLogo className={classes.logo} /> <div className={classes.footerSection}>
<FormattedMessage <IconLogo className={classes.logo} />
id='annual_report.shared_page.footer' <FormattedMessage
defaultMessage='Generated with {heart} by the Mastodon team' id='annual_report.shared_page.footer'
values={{ heart: '🐘' }} defaultMessage='Generated with {heart} by the Mastodon team'
/> values={{ heart: '🐘' }}
<nav className={classes.nav}> tagName='p'
<a href='https://joinmastodon.org'> />
<FormattedMessage id='footer.about' defaultMessage='About' /> <ul className={classes.linkList}>
</a> <li>
{!isLoggedIn && ( <a href='https://joinmastodon.org'>
<a href='https://joinmastodon.org/servers'> <FormattedMessage
<FormattedMessage id='footer.about_mastodon'
id='annual_report.shared_page.sign_up' defaultMessage='About Mastodon'
defaultMessage='Sign up' />
/> </a>
</a> </li>
)} <li>
<a href='https://joinmastodon.org/sponsors'> <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 <FormattedMessage
id='annual_report.shared_page.donate' id='footer.about_server'
defaultMessage='Donate' defaultMessage='About {domain}'
values={{ domain }}
/> />
</a> </a>
</nav> </div>
</footer> </footer>
</main> </main>
); );

View File

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

View File

@@ -4,6 +4,7 @@ import type { List } from 'immutable';
import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; 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 { Status } from '@/mastodon/models/status';
import type { Mention } from './embedded_status'; import type { Mention } from './embedded_status';
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
className={className} className={className}
lang={status.get('language') as string} lang={status.get('language') as string}
htmlString={status.get('contentHtml') 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 { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { layoutFromWindow } from 'mastodon/is_mobile'; import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@@ -396,6 +397,7 @@ class UI extends PureComponent {
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(fetchNotifications()); this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages()); this.props.dispatch(fetchServerTranslationLanguages());
this.props.dispatch(checkAnnualReport());
setTimeout(() => this.props.dispatch(fetchServer()), 3000); 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'; import type { ApiAccountJSON } from './api_types/accounts';
type InitialStateLanguage = [code: string, name: string, localName: string]; type InitialStateLanguage = [code: string, name: string, localName: string];
@@ -47,6 +48,7 @@ interface InitialStateMeta {
status_page_url: string; status_page_url: string;
terms_of_service_enabled: boolean; terms_of_service_enabled: boolean;
emoji_style?: string; emoji_style?: string;
wrapstodon?: InitialWrapstodonState | null;
} }
interface Role { interface Role {
@@ -57,6 +59,11 @@ interface Role {
highlighted: boolean; highlighted: boolean;
} }
interface InitialWrapstodonState {
year: number;
state: ApiAnnualReportState;
}
export interface InitialState { export interface InitialState {
accounts: Record<string, ApiAccountJSON>; accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[]; languages: InitialStateLanguage[];
@@ -128,6 +135,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled'); export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
export const wrapstodon = getMeta('wrapstodon');
const displayNames = const displayNames =
// Intl.DisplayNames can be undefined in old browsers // Intl.DisplayNames can be undefined in old browsers

View File

@@ -121,7 +121,7 @@
"annual_report.nav_item.badge": "New", "annual_report.nav_item.badge": "New",
"annual_report.shared_page.donate": "Donate", "annual_report.shared_page.donate": "Donate",
"annual_report.shared_page.footer": "Generated with {heart} by the Mastodon team", "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_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.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", "annual_report.summary.archetype.booster.name": "The Archer",
@@ -441,6 +441,8 @@
"follow_suggestions.who_to_follow": "Who to follow", "follow_suggestions.who_to_follow": "Who to follow",
"followed_tags": "Followed hashtags", "followed_tags": "Followed hashtags",
"footer.about": "About", "footer.about": "About",
"footer.about_mastodon": "About Mastodon",
"footer.about_server": "About {domain}",
"footer.about_this_server": "About", "footer.about_this_server": "About",
"footer.directory": "Profiles directory", "footer.directory": "Profiles directory",
"footer.get_app": "Get the app", "footer.get_app": "Get the app",

View File

@@ -11,22 +11,25 @@ import {
apiGetAnnualReportState, apiGetAnnualReportState,
apiRequestGenerateAnnualReport, apiRequestGenerateAnnualReport,
} from '@/mastodon/api/annual_report'; } from '@/mastodon/api/annual_report';
import { wrapstodon } from '@/mastodon/initial_state';
import type { AnnualReport } from '@/mastodon/models/annual_report'; import type { AnnualReport } from '@/mastodon/models/annual_report';
import { import {
createAppSelector,
createAppThunk, createAppThunk,
createDataLoadingThunk, createDataLoadingThunk,
} from '../../store/typed_functions'; } from '@/mastodon/store/typed_functions';
interface AnnualReportState { interface AnnualReportState {
year?: number;
state?: ApiAnnualReportState; state?: ApiAnnualReportState;
report?: AnnualReport; report?: AnnualReport;
} }
const annualReportSlice = createSlice({ const annualReportSlice = createSlice({
name: 'annualReport', name: 'annualReport',
initialState: {} as AnnualReportState, initialState: {
year: wrapstodon?.year,
state: wrapstodon?.state,
} as AnnualReportState,
reducers: { reducers: {
setReport(state, action: PayloadAction<AnnualReport>) { setReport(state, action: PayloadAction<AnnualReport>) {
state.report = action.payload; state.report = action.payload;
@@ -52,18 +55,17 @@ const annualReportSlice = createSlice({
export const annualReport = annualReportSlice.reducer; export const annualReport = annualReportSlice.reducer;
export const { setReport } = annualReportSlice.actions; export const { setReport } = annualReportSlice.actions;
export const selectWrapstodonYear = createAppSelector( // Called on initial load to check if we need to refresh the report state.
[(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.
export const checkAnnualReport = createAppThunk( export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
(_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const { state, year } = getState().annualReport;
const me = getState().meta.get('me') as string; 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; return;
} }
void dispatch(fetchReportState()); void dispatch(fetchReportState());
@@ -73,7 +75,7 @@ export const checkAnnualReport = createAppThunk(
const fetchReportState = createDataLoadingThunk( const fetchReportState = createDataLoadingThunk(
`${annualReportSlice.name}/fetchReportState`, `${annualReportSlice.name}/fetchReportState`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }
@@ -84,8 +86,6 @@ const fetchReportState = createDataLoadingThunk(
window.setTimeout(() => { window.setTimeout(() => {
void dispatch(fetchReportState()); void dispatch(fetchReportState());
}, 1_000 * refresh.retry); }, 1_000 * refresh.retry);
} else if (state === 'available') {
void dispatch(getReport());
} }
return state; return state;
@@ -97,7 +97,7 @@ const fetchReportState = createDataLoadingThunk(
export const generateReport = createDataLoadingThunk( export const generateReport = createDataLoadingThunk(
`${annualReportSlice.name}/generateReport`, `${annualReportSlice.name}/generateReport`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }
@@ -111,7 +111,7 @@ export const generateReport = createDataLoadingThunk(
export const getReport = createDataLoadingThunk( export const getReport = createDataLoadingThunk(
`${annualReportSlice.name}/getReport`, `${annualReportSlice.name}/getReport`,
async (_arg: unknown, { getState }) => { async (_arg: unknown, { getState }) => {
const year = selectWrapstodonYear(getState()); const { year } = getState().annualReport;
if (!year) { if (!year) {
throw new Error('Year is not set'); throw new Error('Year is not set');
} }

View File

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

View File

@@ -34,6 +34,25 @@ class AnnualReport
end end
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 def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year) return if GeneratedAnnualReport.exists?(account: @account, year: @year)

View File

@@ -33,7 +33,7 @@ class DomainAllow < ApplicationRecord
def rule_for(domain) def rule_for(domain)
return if domain.blank? 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) find_by(domain: uri.normalized_host)
end end

View File

@@ -48,6 +48,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_content_type] = object_account_user.setting_default_content_type store[:default_content_type] = object_account_user.setting_default_content_type
store[:show_trends] = Setting.trends && object_account_user.setting_trends store[:show_trends] = Setting.trends && object_account_user.setting_trends
store[:emoji_style] = object_account_user.settings['web.emoji_style'] store[:emoji_style] = object_account_user.settings['web.emoji_style']
store[:wrapstodon] = wrapstodon
else else
store[:auto_play_gif] = Setting.auto_play_gif store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media store[:display_media] = Setting.display_media
@@ -110,6 +111,16 @@ class InitialStateSerializer < ActiveModel::Serializer
private 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 def default_meta_store
{ {
access_token: object.token, access_token: object.token,

View File

@@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService
# Make sure we never mention blocked accounts # Make sure we never mention blocked accounts
unless @current_mentions.empty? unless @current_mentions.empty?
mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq 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) 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)) 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-manifest-sri": "^0.2.0",
"vite-plugin-pwa": "^1.0.2", "vite-plugin-pwa": "^1.0.2",
"vite-plugin-svgr": "^4.3.0", "vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^6.0.0",
"wicg-inert": "^3.1.2", "wicg-inert": "^3.1.2",
"workbox-expiration": "^7.3.0", "workbox-expiration": "^7.3.0",
"workbox-routing": "^7.3.0", "workbox-routing": "^7.3.0",
@@ -182,7 +182,7 @@
"lint-staged": "^16.2.6", "lint-staged": "^16.2.6",
"msw": "^2.12.1", "msw": "^2.12.1",
"msw-storybook-addon": "^2.0.6", "msw-storybook-addon": "^2.0.6",
"playwright": "^1.56.1", "playwright": "^1.57.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"storybook": "^10.0.5", "storybook": "^10.0.5",

View File

@@ -68,6 +68,26 @@ RSpec.describe FollowerAccountsController do
end end
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 context 'when account is permanently suspended' do
before do before do
alice.suspend! alice.suspend!

View File

@@ -68,6 +68,26 @@ RSpec.describe FollowingAccountsController do
end end
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 context 'when account is permanently suspended' do
before do before do
alice.suspend! alice.suspend!

View File

@@ -17,7 +17,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do
end end
context 'with account' do 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 it 'returns a labeled avatar link to the account' do
expect(parsed_html.a[:href]).to eq admin_account_path(account.id) expect(parsed_html.a[:href]).to eq admin_account_path(account.id)
@@ -39,7 +39,7 @@ RSpec.describe Admin::AccountModerationNotesHelper do
end end
context 'with account' do context 'with account' do
let(:account) { Fabricate(:account) } let(:account) { Fabricate.build(:account, id: 123) }
it 'returns an inline link to the account' do it 'returns an inline link to the account' do
expect(parsed_html.a[:href]).to eq admin_account_path(account.id) 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 RSpec.describe Admin::DashboardHelper do
describe 'relevant_account_timestamp' do describe 'relevant_account_timestamp' do
let(:account) { Fabricate(:account) }
context 'with an account with older sign in' do context 'with an account with older sign in' do
let(:account) { Fabricate(:account) }
let(:stamp) { 10.days.ago } let(:stamp) { 10.days.ago }
it 'returns a time element' do it 'returns a time element' do
@@ -18,8 +19,6 @@ RSpec.describe Admin::DashboardHelper do
end end
context 'with an account with newer sign in' do context 'with an account with newer sign in' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do it 'returns a time element' do
account.user.update(current_sign_in_at: 10.hours.ago) account.user.update(current_sign_in_at: 10.hours.ago)
result = helper.relevant_account_timestamp(account) result = helper.relevant_account_timestamp(account)
@@ -29,8 +28,6 @@ RSpec.describe Admin::DashboardHelper do
end end
context 'with an account where the user is pending' do context 'with an account where the user is pending' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do it 'returns a time element' do
account.user.update(current_sign_in_at: nil) account.user.update(current_sign_in_at: nil)
account.user.update(approved: false) account.user.update(approved: false)
@@ -42,7 +39,6 @@ RSpec.describe Admin::DashboardHelper do
end end
context 'with an account with a last status value' do context 'with an account with a last status value' do
let(:account) { Fabricate(:account) }
let(:stamp) { 5.minutes.ago } let(:stamp) { 5.minutes.ago }
it 'returns a time element' do it 'returns a time element' do
@@ -56,8 +52,6 @@ RSpec.describe Admin::DashboardHelper do
end end
context 'with an account without sign in or last status or pending' do context 'with an account without sign in or last status or pending' do
let(:account) { Fabricate(:account) }
it 'returns a time element' do it 'returns a time element' do
account.user.update(current_sign_in_at: nil) account.user.update(current_sign_in_at: nil)
result = helper.relevant_account_timestamp(account) 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 context 'with a status that has emoji' do
before { Fabricate(:custom_emoji, shortcode: 'florpy') } 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 it 'renders a correct preview text' do
result = helper.one_line_preview(status) result = helper.one_line_preview(status)

View File

@@ -18,7 +18,7 @@ RSpec.describe FormattingHelper do
end end
context 'with a spoiler and an emoji and a poll' do 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' } before { Fabricate :custom_emoji, shortcode: 'world' }

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,37 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Account::FinderConcern do 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 describe 'local finders' do
let!(:account) { Fabricate(:account, username: 'Alice') } 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) } it { is_expected.to contain_exactly(allowed_domain.domain, other_allowed_domain.domain) }
end end
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 end

View File

@@ -35,6 +35,17 @@ RSpec.describe DomainBlock do
expect(described_class.rule_for('example.com')).to eq block expect(described_class.rule_for('example.com')).to eq block
end 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 it 'returns a rule matching a subdomain of a blocked domain' do
block = Fabricate(:domain_block, domain: 'example.com') block = Fabricate(:domain_block, domain: 'example.com')
expect(described_class.rule_for('sub.example.com')).to eq block expect(described_class.rule_for('sub.example.com')).to eq block

View File

@@ -4,6 +4,8 @@ require 'rails_helper'
RSpec.describe EmailDomainBlock do RSpec.describe EmailDomainBlock do
describe 'block?' do describe 'block?' do
subject { described_class.block?(input) }
let(:input) { nil } let(:input) { nil }
context 'when given an e-mail address' do 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 it 'returns true if the domain is blocked' do
Fabricate(:email_domain_block, domain: 'example.com') Fabricate(:email_domain_block, domain: 'example.com')
expect(described_class.block?(input)).to be true expect(subject).to be true
end end
it 'returns false if the domain is not blocked' do it 'returns false if the domain is not blocked' do
Fabricate(:email_domain_block, domain: 'other-example.com') Fabricate(:email_domain_block, domain: 'other-example.com')
expect(described_class.block?(input)).to be false expect(subject).to be false
end end
end end
@@ -28,7 +30,7 @@ RSpec.describe EmailDomainBlock do
it 'returns true if it is a subdomain of a blocked domain' do it 'returns true if it is a subdomain of a blocked domain' do
Fabricate(:email_domain_block, domain: 'example.com') Fabricate(:email_domain_block, domain: 'example.com')
expect(described_class.block?(input)).to be true expect(subject).to be true
end end
end end
end end
@@ -38,8 +40,40 @@ RSpec.describe EmailDomainBlock do
it 'returns true if the domain is blocked' do it 'returns true if the domain is blocked' do
Fabricate(:email_domain_block, domain: 'mail.foo.com') Fabricate(:email_domain_block, domain: 'mail.foo.com')
expect(described_class.block?(input)).to be true expect(subject).to be true
end end
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
end end

View File

@@ -25,4 +25,36 @@ RSpec.describe PreviewCardProvider do
end end
end 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 end

View File

@@ -39,6 +39,15 @@ RSpec.describe User do
end end
it { is_expected.to allow_value('admin@localhost').for(:email) } 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 end
describe 'Normalizations' do describe 'Normalizations' do

View File

@@ -96,6 +96,28 @@ RSpec.describe '/api/v1/accounts' do
end end
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 context 'when age verification is enabled' do
before do before do
Setting.min_age = 16 Setting.min_age = 16

View File

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

View File

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