mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-28 17:50:01 +01:00
Merge pull request #3316 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 53be8392ec
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 {};
|
||||
2
Gemfile
2
Gemfile
@@ -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'
|
||||
|
||||
72
Gemfile.lock
72
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
30
yarn.lock
30
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user