Merge branch 'main' into main

This commit is contained in:
Goldmaster 2026-01-30 10:26:33 +00:00 committed by GitHub
commit fdd024630d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
385 changed files with 10033 additions and 3430 deletions

View File

@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install"
# Install additional OS packages
RUN apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev
# Disable download prompt for Corepack
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0

View File

@ -0,0 +1,75 @@
name: Compare JS bundle size
on:
pull_request:
paths:
- 'app/javascript/**'
- 'vite.config.mts'
- 'package.json'
- 'yarn.lock'
- .github/workflows/bundlesize-compare.yml
jobs:
build-head:
name: 'Build head'
runs-on: ubuntu-latest
permissions:
contents: read
env:
ANALYZE_BUNDLE_SIZE: '1'
steps:
- uses: actions/checkout@v5
with:
ref: ${{github.event.pull_request.head.ref}}
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Build
run: yarn run build:production
- name: Upload stats.json
uses: actions/upload-artifact@v5
with:
name: head-stats
path: ./stats.json
if-no-files-found: error
build-base:
name: 'Build base'
runs-on: ubuntu-latest
permissions:
contents: read
env:
ANALYZE_BUNDLE_SIZE: '1'
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.base_ref }}
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Build
run: yarn run build:production
- name: Upload stats.json
uses: actions/upload-artifact@v5
with:
name: base-stats
path: ./stats.json
if-no-files-found: error
compare:
name: 'Compare base & head bundle sizes'
runs-on: ubuntu-latest
needs: [build-base, build-head]
permissions:
pull-requests: write
steps:
- uses: actions/download-artifact@v5
- uses: twk3/rollup-size-compare-action@v1.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head-stats/stats.json
base-stats-json-path: ./base-stats/stats.json

View File

@ -58,5 +58,5 @@ jobs:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true
storybookBuildDir: 'storybook-static'
exitZeroOnChanges: false # Fail workflow if changes are found
exitOnceUploaded: true # Exit immediately after upload
autoAcceptChanges: 'main' # Auto-accept changes on main branch only

View File

@ -173,93 +173,6 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-imagemagick:
name: ImageMagick tests
runs-on: ubuntu-latest
needs:
- build
services:
postgres:
image: postgres:14-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
options: >-
--health-cmd pg_isready
--health-interval 10ms
--health-timeout 3s
--health-retries 50
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10ms
--health-timeout 3s
--health-retries 50
ports:
- 6379:6379
env:
DB_HOST: localhost
DB_USER: postgres
DB_PASS: postgres
COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }}
RAILS_ENV: test
ALLOW_NOPAM: true
PAM_ENABLED: true
PAM_DEFAULT_SERVICE: pam_test
PAM_CONTROLLED_SERVICE: pam_test_controlled
OIDC_ENABLED: true
OIDC_SCOPE: read
SAML_ENABLED: true
CAS_ENABLED: true
BUNDLE_WITH: 'pam_authentication test'
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
MASTODON_USE_LIBVIPS: false
strategy:
fail-fast: false
matrix:
ruby-version:
- '3.2'
- '3.3'
- '.ruby-version'
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
path: './'
name: ${{ github.sha }}
- name: Expand archived asset artifacts
run: |
tar xvzf artifacts.tar.gz
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick libpam-dev
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
- run: bin/rspec --tag attachment_processing
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v5
with:
files: coverage/lcov/mastodon.lcov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
name: End to End testing
runs-on: ubuntu-latest

2
.nvmrc
View File

@ -1 +1 @@
24.12
24.13

8
.storybook/modes.ts Normal file
View File

@ -0,0 +1,8 @@
export const modes = {
darkTheme: {
theme: 'dark',
},
lightTheme: {
theme: 'light',
},
} as const;

View File

@ -1,2 +1,2 @@
<html class="no-reduce-motion theme-light">
<html class="no-reduce-motion" data-color-scheme="light">
</html>

View File

@ -25,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
import './styles.css';
import { modes } from './modes';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
@ -50,9 +51,19 @@ const preview: Preview = {
dynamicTitle: true,
},
},
theme: {
description: 'Theme for the story',
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: [{ value: 'light' }, { value: 'dark' }],
dynamicTitle: true,
},
},
},
initialGlobals: {
locale: 'en',
theme: 'light',
},
decorators: [
(Story, { parameters, globals, args, argTypes }) => {
@ -135,6 +146,13 @@ const preview: Preview = {
</IntlProvider>
);
},
(Story, { globals }) => {
const theme = (globals.theme as string) || 'light';
useEffect(() => {
document.body.setAttribute('data-color-scheme', theme);
}, [theme]);
return <Story />;
},
(Story) => (
<MemoryRouter>
<Story />
@ -181,6 +199,13 @@ const preview: Preview = {
msw: {
handlers: mockHandlers,
},
chromatic: {
modes: {
dark: modes.darkTheme,
light: modes.lightTheme,
},
},
},
};

View File

@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file.
## [4.5.5] - 2026-01-20
### Security
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
### Changed
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
### Fixed
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
## [4.5.4] - 2026-01-07
### Security

View File

@ -70,8 +70,6 @@ ENV \
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
# Optimize jemalloc 5.x performance
MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \
# Enable libvips, should not be changed
MASTODON_USE_LIBVIPS=true \
# Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes
MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs
@ -183,7 +181,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.17.3
ARG VIPS_VERSION=8.18.0
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

View File

@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
### Additional documentation
- [Mastodon documentation](https://docs.joinmastodon.org/)
## Size limits
Mastodon imposes a few hard limits on federated content.
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
The following table summarizes those limits.
| Limited property | Size limit | Consequence of exceeding the limit |
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
| Account `attributionDomains` | 256 | List will be truncated |
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated |

14
Gemfile
View File

@ -55,7 +55,7 @@ gem 'hiredis-client'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 5.3.0'
gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.7.0', require: false
gem 'httplog', '~> 1.8.0', require: false
gem 'i18n'
gem 'idn-ruby', require: 'idn'
gem 'inline_svg'
@ -109,12 +109,12 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.34.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false

View File

@ -96,8 +96,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1201.0)
aws-sdk-core (3.241.3)
aws-partitions (1.1206.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -105,11 +105,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@ -193,9 +193,9 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.2.0)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (~> 4.0)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
rotp (~> 6.0)
devise_pam_authenticatable2 (9.2.0)
@ -234,7 +234,7 @@ GEM
excon (1.3.2)
logger
fabrication (3.0.0)
faker (3.5.3)
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
@ -282,7 +282,7 @@ GEM
rake (>= 13)
googleapis-common-protos-types (1.22.0)
google-protobuf (~> 4.26)
haml (7.1.0)
haml (7.2.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.68.0)
haml_lint (0.69.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -305,8 +305,8 @@ GEM
highline (3.1.2)
reline
hiredis (0.6.3)
hiredis-client (0.26.3)
redis-client (= 0.26.3)
hiredis-client (0.26.4)
redis-client (= 0.26.4)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.3.1)
@ -320,7 +320,8 @@ GEM
http_accept_language (2.1.1)
httpclient (2.9.0)
mutex_m
httplog (1.7.3)
httplog (1.8.0)
benchmark
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.14.8)
@ -520,7 +521,8 @@ GEM
opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.3.0)
opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql-processor (0.3.1)
opentelemetry-helpers-sql-processor (0.4.0)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10)
@ -544,17 +546,17 @@ GEM
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-excon (0.26.1)
opentelemetry-instrumentation-excon (0.27.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-faraday (0.30.1)
opentelemetry-instrumentation-faraday (0.31.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http (0.27.1)
opentelemetry-instrumentation-http (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http_client (0.26.1)
opentelemetry-instrumentation-http_client (0.27.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.26.1)
opentelemetry-instrumentation-net_http (0.27.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-pg (0.34.1)
opentelemetry-instrumentation-pg (0.35.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-processor
opentelemetry-instrumentation-base (~> 0.25)
@ -587,7 +589,7 @@ GEM
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.10.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@ -611,7 +613,7 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.7.0)
prism (1.8.0)
prometheus_exporter (2.3.1)
webrick
propshaft (1.3.1)
@ -698,7 +700,7 @@ GEM
readline (~> 0.0)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (7.0.3)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
@ -706,7 +708,7 @@ GEM
reline
redcarpet (3.6.1)
redis (4.8.1)
redis-client (0.26.3)
redis-client (0.26.4)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@ -720,10 +722,10 @@ GEM
rotp (6.3.0)
rouge (4.7.0)
rpam2 (4.0.2)
rqrcode (3.1.1)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rqrcode_core (2.1.0)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
@ -752,7 +754,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.6)
rubocop (1.81.7)
rubocop (1.84.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -760,7 +762,7 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
@ -782,7 +784,7 @@ GEM
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.8.0)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
rubocop-rspec_rails (2.32.0)
@ -827,8 +829,9 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
thor (>= 1.0, < 3.0)
simple-navigation (4.4.0)
simple-navigation (4.4.1)
activesupport (>= 2.3.2)
ostruct
simple_form (5.4.1)
actionpack (>= 7.0)
activemodel (>= 7.0)
@ -859,9 +862,9 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
climate_control
test-prof (1.5.0)
thor (1.4.0)
tilt (2.6.1)
test-prof (1.5.1)
thor (1.5.0)
tilt (2.7.0)
timeout (0.6.0)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
@ -985,7 +988,7 @@ DEPENDENCIES
htmlentities (~> 4.3)
http (~> 5.3.0)
http_accept_language (~> 2.1)
httplog (~> 1.7.0)
httplog (~> 1.8.0)
i18n
i18n-tasks (~> 1.0)
idn-ruby
@ -1021,12 +1024,12 @@ DEPENDENCIES
opentelemetry-instrumentation-active_job (~> 0.10.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
opentelemetry-instrumentation-excon (~> 0.26.0)
opentelemetry-instrumentation-faraday (~> 0.30.0)
opentelemetry-instrumentation-http (~> 0.27.0)
opentelemetry-instrumentation-http_client (~> 0.26.0)
opentelemetry-instrumentation-net_http (~> 0.26.0)
opentelemetry-instrumentation-pg (~> 0.34.0)
opentelemetry-instrumentation-excon (~> 0.27.0)
opentelemetry-instrumentation-faraday (~> 0.31.0)
opentelemetry-instrumentation-http (~> 0.28.0)
opentelemetry-instrumentation-http_client (~> 0.27.0)
opentelemetry-instrumentation-net_http (~> 0.27.0)
opentelemetry-instrumentation-pg (~> 0.35.0)
opentelemetry-instrumentation-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-redis (~> 0.28.0)

View File

@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| 4.5.x | Yes |
| 4.4.x | Yes |
| 4.3.x | Until 2026-05-06 |
| 4.2.x | Until 2026-01-08 |
| < 4.2 | No |
| < 4.3 | No |

1
Vagrantfile vendored
View File

@ -29,7 +29,6 @@ sudo apt-get install \
libpq-dev \
libxml2-dev \
libxslt1-dev \
imagemagick \
nodejs \
redis-server \
redis-tools \

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class ActivityPub::CollectionsController < ActivityPub::BaseController
SUPPORTED_COLLECTIONS = %w(featured tags).freeze
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class ActivityPub::FeaturedCollectionsController < ApplicationController
include SignatureAuthentication
include Authorization
include AccountOwnedConcern
PER_PAGE = 5
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
before_action :check_feature_enabled
before_action :require_account_signature!, if: -> { authorized_fetch_mode? }
before_action :set_collections
skip_around_action :set_locale
skip_before_action :require_functional!, unless: :limited_federation_mode?
def index
respond_to do |format|
format.json do
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end
private
def set_collections
authorize @account, :index_collections?
@collections = @account.collections.page(params[:page]).per(PER_PAGE)
rescue Mastodon::NotPermittedError
not_found
end
def page_requested?
params[:page].present?
end
def next_page_url
ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page)
end
def prev_page_url
ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page)
end
def collection_presenter
if page_requested?
ActivityPub::CollectionPresenter.new(
id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)),
type: :unordered,
size: @account.collections.count,
items: @collections,
part_of: ap_account_featured_collections_url(@account),
next: next_page_url,
prev: prev_page_url
)
else
ActivityPub::CollectionPresenter.new(
id: ap_account_featured_collections_url(@account),
type: :unordered,
size: @account.collections.count,
first: ap_account_featured_collections_url(@account, page: 1)
)
end
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
end

View File

@ -3,6 +3,7 @@
class ActivityPub::InboxesController < ActivityPub::BaseController
include JsonLdHelper
before_action :skip_large_payload
before_action :skip_unknown_actor_activity
before_action :require_actor_signature!
skip_before_action :authenticate_user!
@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
private
def skip_large_payload
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
end
def skip_unknown_actor_activity
head 202 if unknown_affected_account?
end

View File

@ -9,9 +9,9 @@ class Api::V1::Accounts::NotesController < Api::BaseController
def create
if params[:comment].blank?
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
current_account.account_notes.find_by(target_account: @account)&.destroy
else
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
@note = current_account.account_notes.find_or_initialize_by(target_account: @account)
@note.comment = params[:comment]
@note.save! if @note.changed?
end

View File

@ -21,7 +21,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
@item = AddAccountToCollectionService.new.call(@collection, @account)
render json: @item, serializer: REST::CollectionItemSerializer
render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json
end
def destroy

View File

@ -26,16 +26,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
def index
cache_if_unauthenticated!
authorize Collection, :index?
authorize @account, :index_collections?
render json: @collections, each_serializer: REST::BaseCollectionSerializer
render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
rescue Mastodon::NotPermittedError
render json: { collections: [] }
end
def show
cache_if_unauthenticated!
authorize @collection, :show?
render json: @collection, serializer: REST::CollectionSerializer
render json: @collection, serializer: REST::CollectionWithAccountsSerializer
end
def create
@ -43,7 +45,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
@collection = CreateCollectionService.new.call(collection_creation_params, current_user.account)
render json: @collection, serializer: REST::CollectionSerializer
render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
end
def update
@ -51,7 +53,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
@collection.update!(collection_update_params) # TODO: Create a service for this to federate changes
render json: @collection, serializer: REST::CollectionSerializer
render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
end
def destroy
@ -74,6 +76,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
.order(created_at: :desc)
.offset(offset_param)
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
@collections = @collections.discoverable unless @account == current_account
end
def set_collection
@ -81,11 +84,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
end
def collection_creation_params
params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: [])
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: [])
end
def collection_update_params
params.permit(:name, :description, :sensitive, :discoverable, :tag_name)
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name)
end
def check_feature_enabled

View File

@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def set_push_subscription
@push_subscription = ::Web::PushSubscription.find(params[:id])
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
end
def subscription_params

View File

@ -130,14 +130,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def require_rules_acceptance!
return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token])
return if @rules.empty? || validated_accept_token?
@accept_token = session[:accept_token] = SecureRandom.hex
@invite_code = invite_code
@invite_code = invite_code
@rule_translations = @rules.map { |rule| rule.translation_for(I18n.locale) }
render :rules
end
def validated_accept_token?
session[:accept_token].present? && params[:accept] == session[:accept_token]
end
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
if params[:action] == 'create'
false # Disable flash messages for sign-up

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
class CollectionsController < ApplicationController
include WebAppControllerConcern
include SignatureAuthentication
include Authorization
include AccountOwnedConcern
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
before_action :check_feature_enabled
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_collection
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
def show
respond_to do |format|
# TODO: format.html
format.json do
expires_in expiration_duration, public: true if public_fetch_mode?
render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def set_collection
@collection = @account.collections.find(params[:id])
authorize @collection, :show?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
not_found
end
def expiration_duration
recently_updated = @collection.updated_at > 15.minutes.ago
recently_updated ? 30.seconds : 5.minutes
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end
end

View File

@ -4,10 +4,8 @@ module AccountableConcern
extend ActiveSupport::Concern
def log_action(action, target)
Admin::ActionLog.create(
account: current_account,
action: action,
target: target
)
current_account
.action_logs
.create(action:, target:)
end
end

View File

@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController
before_action :authenticate_user!, if: :limited_federation_mode?
before_action :set_media_attachment
rescue_from ActiveRecord::RecordInvalid, with: :not_found
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
rescue_from Mastodon::NotPermittedError, with: :not_found
rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
def show

View File

@ -20,14 +20,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
store_location_for(:user, request.url)
end
def render_success
if skip_authorization? || (matching_token? && !truthy_param?('force_login'))
redirect_or_render authorize_response
elsif Doorkeeper.configuration.api_only
render json: pre_auth
else
render :new
end
def can_authorize_response?
!truthy_param?('force_login') && super
end
def truthy_param?(key)

View File

@ -29,7 +29,7 @@ class StatusesController < ApplicationController
end
format.json do
expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode?
expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode?
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
end
end

View File

@ -89,6 +89,12 @@ module ApplicationHelper
Rails.env.production? ? site_title : "#{site_title} (Dev)"
end
def page_color_scheme
return content_for(:force_color_scheme) if content_for(:force_color_scheme)
color_scheme
end
def label_for_scope(scope)
safe_join [
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
@ -153,6 +159,19 @@ module ApplicationHelper
tag.meta(content: content, property: property)
end
def html_attributes
base = {
lang: I18n.locale,
class: html_classes,
'data-contrast': contrast.parameterize,
'data-color-scheme': page_color_scheme.parameterize,
}
base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto'
base
end
def html_classes
output = []
output << content_for(:html_classes)

View File

@ -233,6 +233,7 @@ module LanguagesHelper
'es-AR': 'Español (Argentina)',
'es-MX': 'Español (México)',
'fr-CA': 'Français (Canadien)',
'nan-TW': '臺語 (Hô-ló話)',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
'sr-Latn': 'Srpski (latinica)',

View File

@ -18,24 +18,23 @@ module ThemeHelper
end
def theme_style_tags(theme)
if theme == 'system'
''.html_safe.tap do |tags|
tags << vite_stylesheet_tag('themes/mastodon-light', type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << vite_stylesheet_tag('themes/default', type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
end
else
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
end
# TODO: get rid of that when we retire the themes and perform the settings migration
theme = 'default' if %w(mastodon-light contrast system).include?(theme)
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
end
def theme_color_tags(theme)
if theme == 'system'
def theme_color_tags(color_scheme)
case color_scheme
when 'auto'
''.html_safe.tap do |tags|
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)')
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)')
end
else
tag.meta name: 'theme-color', content: theme_color_for(theme)
when 'light'
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:light]
when 'dark'
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:dark]
end
end
@ -65,8 +64,4 @@ module ThemeHelper
Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) }
end
end
def theme_color_for(theme)
theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark]
end
end

View File

@ -0,0 +1 @@
import '../inline/theme-selection';

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="url(#badgeGradient)" d="M8 .666c.301 0 .595.094.839.268l.101.079.006.005c1.092.956 2.603 1.649 3.721 1.649A1.333 1.333 0 0 1 14 4v4.667c0 1.842-.652 3.259-1.702 4.336-1.032 1.058-2.417 1.76-3.852 2.26l-.005.002a1.334 1.334 0 0 1-.879-.01c-1.437-.496-2.825-1.195-3.858-2.253C2.654 11.926 2 10.509 2 8.667V4a1.334 1.334 0 0 1 1.334-1.333c1.118 0 2.634-.7 3.72-1.65l.007-.004C7.322.789 7.656.666 8 .666Z"/>
<path stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round" d="M5 10.998A3.053 3.053 0 0 1 6.173 9.57a3.333 3.333 0 0 1 1.828-.54m0 0c.653 0 1.29.189 1.826.54.537.353.946.852 1.173 1.43M8 9.03c1.179 0 2.133-.903 2.133-2.015C10.133 5.902 9.178 5 8 5c-1.179 0-2.134.902-2.134 2.015 0 1.112.956 2.015 2.135 2.015Z"/>
<defs>
<linearGradient id="badgeGradient" x1=".5" x2="14" y1="2.5" y2="15" gradientUnits="userSpaceOnUse">
<stop stop-color="#E17100"/>
<stop offset="1" stop-color="#F0B100"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
<path fill="url(#VerifiedGradient)" d="M8 .837a3.168 3.168 0 0 1 2.47 1.187 3.166 3.166 0 0 1 2.601.906 3.168 3.168 0 0 1 .905 2.6A3.167 3.167 0 0 1 15.164 8a3.172 3.172 0 0 1-1.188 2.47 3.167 3.167 0 0 1-.903 2.597 3.168 3.168 0 0 1-2.596.909 3.167 3.167 0 0 1-4.95.001 3.166 3.166 0 0 1-3.397-2.258 3.169 3.169 0 0 1-.107-1.24A3.168 3.168 0 0 1 .826 8a3.17 3.17 0 0 1 1.197-2.479 3.168 3.168 0 0 1 .91-2.593 3.166 3.166 0 0 1 2.596-.905A3.169 3.169 0 0 1 8 .837Z"/>
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="m6 8 1.333 1.333L10 6.667"/>
<defs>
<linearGradient id="VerifiedGradient" x1="-.966" x2="12.162" y1="2.629" y2="17.493" gradientUnits="userSpaceOnUse">
<stop offset=".13" stop-color="#5638CC"/>
<stop offset=".995" stop-color="#DC03F0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 930 B

View File

@ -1,16 +1,17 @@
(function (element) {
const {userTheme} = element.dataset;
const {colorScheme, contrast} = element.dataset;
const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)');
const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)');
const updateColorScheme = () => {
const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light';
element.dataset.mode = useDarkMode ? 'dark' : 'light';
const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark';
element.dataset.colorScheme = useDarkMode ? 'dark' : 'light';
};
const updateContrast = () => {
const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches;
const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches;
element.dataset.contrast = useHighContrast ? 'high' : 'default';
}

View File

@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
const DIRECTORY_FETCH_LIMIT = 20;
export const fetchDirectory = createDataLoadingThunk(
'directory/fetch',
async (params: Parameters<typeof apiGetDirectory>[0]) =>
apiGetDirectory(params),
apiGetDirectory(params, DIRECTORY_FETCH_LIMIT),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(data.map((x) => x.id)));
return { accounts: data };
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
},
);
@ -26,12 +28,15 @@ export const expandDirectory = createDataLoadingThunk(
'items',
]) as ImmutableList<unknown>;
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
return apiGetDirectory(
{ ...params, offset: loadedItems.size },
DIRECTORY_FETCH_LIMIT,
);
},
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(data.map((x) => x.id)));
return { accounts: data };
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
},
);

View File

@ -27,7 +27,15 @@ export const fetchServer = () => (dispatch, getState) => {
api()
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
// Only import the account if it doesn't already exist,
// because the API is cached even for logged in users.
const account = data.contact.account;
if (account) {
const existingAccount = getState().getIn(['accounts', account.id]);
if (!existingAccount) {
dispatch(importFetchedAccount(account));
}
}
dispatch(fetchServerSuccess(data));
}).catch(err => dispatch(fetchServerFail(err)));
};

View File

@ -0,0 +1,60 @@
import { parseTimelineKey, timelineKey } from './timelines_typed';
describe('timelineKey', () => {
test('returns expected key for account timeline with filters', () => {
const key = timelineKey({
type: 'account',
userId: '123',
replies: true,
boosts: false,
media: true,
});
expect(key).toBe('account:123:0110');
});
test('returns expected key for account timeline with tag', () => {
const key = timelineKey({
type: 'account',
userId: '456',
tagged: 'nature',
replies: true,
});
expect(key).toBe('account:456:0100:nature');
});
test('returns expected key for account timeline with pins', () => {
const key = timelineKey({
type: 'account',
userId: '789',
pinned: true,
});
expect(key).toBe('account:789:0001');
});
});
describe('parseTimelineKey', () => {
test('parses account timeline key with filters correctly', () => {
const params = parseTimelineKey('account:123:1010');
expect(params).toEqual({
type: 'account',
userId: '123',
boosts: true,
replies: false,
media: true,
pinned: false,
});
});
test('parses account timeline key with tag correctly', () => {
const params = parseTimelineKey('account:456:0100:nature');
expect(params).toEqual({
type: 'account',
userId: '456',
replies: true,
boosts: false,
media: false,
pinned: false,
tagged: 'nature',
});
});
});

View File

@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
import { createAppThunk } from '../store/typed_functions';
import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines';
export const expandTimelineByKey = createAppThunk(
(args: { key: string; maxId?: number }, { dispatch }) => {
const params = parseTimelineKey(args.key);
if (!params) {
return;
}
void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId }));
},
);
export const expandTimelineByParams = createAppThunk(
(params: TimelineParams & { maxId?: number }, { dispatch }) => {
let url = '';
const extra: Record<string, string | boolean> = {};
if (params.type === 'account') {
url = `/api/v1/accounts/${params.userId}/statuses`;
if (!params.replies) {
extra.exclude_replies = true;
}
if (!params.boosts) {
extra.exclude_reblogs = true;
}
if (params.pinned) {
extra.pinned = true;
}
if (params.media) {
extra.only_media = true;
}
if (params.tagged) {
extra.tagged = params.tagged;
}
} else if (params.type === 'public') {
url = '/api/v1/timelines/public';
}
if (params.maxId) {
extra.max_id = params.maxId.toString();
}
return dispatch(expandTimeline(timelineKey(params), url, extra));
},
);
export interface AccountTimelineParams {
type: 'account';
userId: string;
tagged?: string;
media?: boolean;
pinned?: boolean;
boosts?: boolean;
replies?: boolean;
}
export type PublicTimelineServer = 'local' | 'remote' | 'all';
export interface PublicTimelineParams {
type: 'public';
tagged?: string;
server?: PublicTimelineServer; // Defaults to 'all'
media?: boolean;
}
export interface HomeTimelineParams {
type: 'home';
}
export type TimelineParams =
| AccountTimelineParams
| PublicTimelineParams
| HomeTimelineParams;
const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const;
export function timelineKey(params: TimelineParams): string {
const { type } = params;
const key: string[] = [type];
if (type === 'account') {
key.push(params.userId);
const view = ACCOUNT_FILTERS.reduce(
(prev, curr) => prev + (params[curr] ? '1' : '0'),
'',
);
key.push(view);
} else if (type === 'public') {
key.push(params.server ?? 'all');
if (params.media) {
key.push('media');
}
}
if (type !== 'home' && params.tagged) {
key.push(params.tagged);
}
return key.filter(Boolean).join(':');
}
export function parseTimelineKey(key: string): TimelineParams | null {
const segments = key.split(':');
const type = segments[0];
if (type === 'account') {
const userId = segments[1];
if (!userId) {
return null;
}
const parsed: TimelineParams = {
type: 'account',
userId,
tagged: segments[3],
};
const view = segments[2]?.split('') ?? [];
for (let i = 0; i < view.length; i++) {
const flagName = ACCOUNT_FILTERS[i];
if (flagName) {
parsed[flagName] = view[i] === '1';
}
}
return parsed;
}
if (type === 'public') {
return {
type: 'public',
server:
segments[1] === 'remote' || segments[1] === 'local'
? segments[1]
: 'all',
tagged: segments[2],
media: segments[3] === 'media',
};
}
if (type === 'home') {
return { type: 'home' };
}
return null;
}
export function isNonStatusId(value: unknown) {
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);

View File

@ -128,15 +128,18 @@ export default function api(withAuthorization = true) {
}
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
type RequestParamsOrData = Record<string, unknown>;
type RequestParamsOrData<T = unknown> = T | Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>(
export async function apiRequest<
ApiResponse = unknown,
ApiParamsOrData = unknown,
>(
method: Method,
url: string,
args: {
signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
params?: RequestParamsOrData<ApiParamsOrData>;
data?: RequestParamsOrData<ApiParamsOrData>;
timeout?: number;
} = {},
) {
@ -149,30 +152,30 @@ export async function apiRequest<ApiResponse = unknown>(
return data;
}
export async function apiRequestGet<ApiResponse = unknown>(
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
url: ApiUrl,
params?: RequestParamsOrData,
params?: RequestParamsOrData<ApiParams>,
) {
return apiRequest<ApiResponse>('GET', url, { params });
}
export async function apiRequestPost<ApiResponse = unknown>(
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl,
data?: RequestParamsOrData,
data?: RequestParamsOrData<ApiData>,
) {
return apiRequest<ApiResponse>('POST', url, { data });
}
export async function apiRequestPut<ApiResponse = unknown>(
export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl,
data?: RequestParamsOrData,
data?: RequestParamsOrData<ApiData>,
) {
return apiRequest<ApiResponse>('PUT', url, { data });
}
export async function apiRequestDelete<ApiResponse = unknown>(
url: ApiUrl,
params?: RequestParamsOrData,
) {
export async function apiRequestDelete<
ApiResponse = unknown,
ApiParams = unknown,
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params });
}

View File

@ -0,0 +1,39 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type {
ApiWrappedCollectionJSON,
ApiCollectionWithAccountsJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
ApiCollectionsJSON,
} from '../api_types/collections';
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
apiRequestPost<ApiWrappedCollectionJSON>('v1_alpha/collections', collection);
export const apiUpdateCollection = ({
id,
...collection
}: ApiUpdateCollectionPayload) =>
apiRequestPut<ApiWrappedCollectionJSON>(
`v1_alpha/collections/${id}`,
collection,
);
export const apiDeleteCollection = (collectionId: string) =>
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
export const apiGetCollection = (collectionId: string) =>
apiRequestGet<ApiCollectionWithAccountsJSON>(
`v1_alpha/collections/${collectionId}`,
);
export const apiGetAccountCollections = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/collections`,
);

View File

@ -0,0 +1,82 @@
// See app/serializers/rest/base_collection_serializer.rb
import type { ApiAccountJSON } from './accounts';
import type { ApiTagJSON } from './statuses';
/**
* Returned when fetching all collections for an account,
* doesn't contain account and item data
*/
export interface ApiCollectionJSON {
account_id: string;
id: string;
uri: string;
local: boolean;
item_count: number;
name: string;
description: string;
tag?: ApiTagJSON;
language: string;
sensitive: boolean;
discoverable: boolean;
created_at: string;
updated_at: string;
items: CollectionAccountItem[];
}
/**
* Returned when fetching all collections for an account
*/
export interface ApiCollectionsJSON {
collections: ApiCollectionJSON[];
}
/**
* Returned when creating, updating, and adding to a collection
*/
export interface ApiWrappedCollectionJSON {
collection: ApiCollectionJSON;
}
/**
* Returned when fetching a single collection
*/
export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON {
accounts: ApiAccountJSON[];
}
/**
* Nested account item
*/
interface CollectionAccountItem {
account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
state: 'pending' | 'accepted' | 'rejected' | 'revoked';
position: number;
}
export interface WrappedCollectionAccountItem {
collection_item: CollectionAccountItem;
}
/**
* Payload types
*/
type CommonPayloadFields = Pick<
ApiCollectionJSON,
'name' | 'description' | 'sensitive' | 'discoverable'
> & {
tag_name?: string;
};
export interface ApiUpdateCollectionPayload extends Partial<CommonPayloadFields> {
id: string;
}
export interface ApiCreateCollectionPayload extends CommonPayloadFields {
account_ids?: string[];
}

View File

@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
export const Badge = ({ icon = <PersonIcon />, label, domain, roleId }) => (
<div className='account-role' data-account-role-id={roleId}>
{icon}
{label}
{domain && <span className='account-role__domain'>{domain}</span>}
</div>
);
Badge.propTypes = {
icon: PropTypes.node,
label: PropTypes.node,
domain: PropTypes.node,
roleId: PropTypes.string
};
export const GroupBadge = () => (
<Badge icon={<GroupsIcon />} label={<FormattedMessage id='account.badges.group' defaultMessage='Group' />} />
);
export const AutomatedBadge = () => (
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
);

View File

@ -0,0 +1,46 @@
import type { FC, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
export const Badge: FC<{
label: ReactNode;
icon?: ReactNode;
className?: string;
domain?: ReactNode;
roleId?: string;
}> = ({ icon = <PersonIcon />, label, className, domain, roleId }) => (
<div
className={classNames('account-role', className)}
data-account-role-id={roleId}
>
{icon}
{label}
{domain && <span className='account-role__domain'>{domain}</span>}
</div>
);
export const GroupBadge: FC<{ className?: string }> = ({ className }) => (
<Badge
icon={<GroupsIcon />}
label={
<FormattedMessage id='account.badges.group' defaultMessage='Group' />
}
className={className}
/>
);
export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => (
<Badge
icon={<SmartToyIcon />}
label={
<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />
}
className={className}
/>
);

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Callout } from '.';
const meta = {
title: 'Components/Callout',
args: {
children: 'Contents here',
title: 'Title',
onPrimary: action('Primary action clicked'),
primaryLabel: 'Primary',
onSecondary: action('Secondary action clicked'),
secondaryLabel: 'Secondary',
onClose: action('Close clicked'),
},
component: Callout,
render(args) {
return (
<div style={{ minWidth: 'min(400px, calc(100vw - 2rem))' }}>
<Callout {...args} />
</div>
);
},
} satisfies Meta<typeof Callout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
variant: 'default',
},
};
export const NoIcon: Story = {
args: {
icon: false,
},
};
export const NoActions: Story = {
args: {
onPrimary: undefined,
onSecondary: undefined,
},
};
export const OnlyText: Story = {
args: {
onClose: undefined,
onPrimary: undefined,
onSecondary: undefined,
icon: false,
},
};
// export const Subtle: Story = {
// args: {
// variant: 'subtle',
// },
// };
export const Feature: Story = {
args: {
variant: 'feature',
},
};
export const Inverted: Story = {
args: {
variant: 'inverted',
},
};
export const Success: Story = {
args: {
variant: 'success',
},
};
export const Warning: Story = {
args: {
variant: 'warning',
},
};
export const Error: Story = {
args: {
variant: 'error',
},
};

View File

@ -0,0 +1,27 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import { Callout } from '.';
import type { CalloutProps } from '.';
type DismissibleCalloutProps = CalloutProps & {
id: string;
};
export const DismissibleCallout: FC<DismissibleCalloutProps> = (props) => {
const { dismiss, wasDismissed } = useDismissible(props.id);
const { onClose } = props;
const handleClose = useCallback(() => {
dismiss();
onClose?.();
}, [dismiss, onClose]);
if (wasDismissed) {
return null;
}
return <Callout {...props} onClose={handleClose} />;
};

View File

@ -0,0 +1,154 @@
import type { FC, ReactNode } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { IconProp } from '../icon';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import classes from './styles.module.css';
export interface CalloutProps {
variant?:
| 'default'
// | 'subtle'
| 'feature'
| 'inverted'
| 'success'
| 'warning'
| 'error';
title?: ReactNode;
children: ReactNode;
className?: string;
/** Set to false to hide the icon. */
icon?: IconProp | boolean;
onPrimary?: () => void;
primaryLabel?: string;
onSecondary?: () => void;
secondaryLabel?: string;
onClose?: () => void;
id?: string;
extraContent?: ReactNode;
}
const variantClasses = {
default: classes.variantDefault as string,
// subtle: classes.variantSubtle as string,
feature: classes.variantFeature as string,
inverted: classes.variantInverted as string,
success: classes.variantSuccess as string,
warning: classes.variantWarning as string,
error: classes.variantError as string,
} as const;
export const Callout: FC<CalloutProps> = ({
className,
variant = 'default',
title,
children,
icon,
onPrimary: primaryAction,
primaryLabel,
onSecondary: secondaryAction,
secondaryLabel,
onClose,
extraContent,
id,
}) => {
const intl = useIntl();
return (
<aside
className={classNames(
className,
classes.wrapper,
variantClasses[variant],
)}
data-variant={variant}
id={id}
>
<CalloutIcon variant={variant} icon={icon} />
<div className={classes.content}>
<div className={classes.body}>
{title && <h3>{title}</h3>}
{children}
</div>
{(primaryAction ?? secondaryAction) && (
<div className={classes.actionWrapper}>
{secondaryAction && (
<button
type='button'
onClick={secondaryAction}
className={classes.action}
>
{secondaryLabel ?? 'Click'}
</button>
)}
{primaryAction && (
<button
type='button'
onClick={primaryAction}
className={classes.action}
>
{primaryLabel ?? 'Click'}
</button>
)}
</div>
)}
</div>
{extraContent}
{onClose && (
<IconButton
icon='close'
title={intl.formatMessage({
id: 'callout.dismiss',
defaultMessage: 'Dismiss',
})}
iconComponent={CloseIcon}
className={classes.close}
onClick={onClose}
/>
)}
</aside>
);
};
const CalloutIcon: FC<Pick<CalloutProps, 'variant' | 'icon'>> = ({
variant = 'default',
icon,
}) => {
if (icon === false) {
return null;
}
if (!icon || icon === true) {
switch (variant) {
case 'inverted':
case 'success':
icon = CheckIcon;
break;
case 'warning':
icon = WarningIcon;
break;
case 'error':
icon = ErrorIcon;
break;
default:
icon = InfoIcon;
}
}
return <Icon id={variant} icon={icon} className={classes.icon} />;
};

View File

@ -0,0 +1,128 @@
.wrapper {
display: flex;
align-items: start;
padding: 12px;
gap: 8px;
background-color: var(--color-bg-brand-softer);
color: var(--color-text-primary);
border-radius: 12px;
}
.icon {
padding: 4px;
border-radius: 9999px;
width: 1rem;
height: 1rem;
margin-top: -2px;
}
.content {
display: flex;
gap: 8px;
flex-direction: column;
flex-grow: 1;
}
@media screen and (width >= 630px) {
.content {
flex-direction: row;
}
}
.body {
flex-grow: 1;
h3 {
font-weight: 500;
margin-bottom: 5px;
}
}
.actionWrapper {
display: flex;
gap: 8px;
align-items: start;
}
.action {
appearance: none;
background: none;
border: none;
color: inherit;
font-weight: 500;
padding: 0;
text-decoration: underline;
transition: color 0.1s ease-in-out;
&:hover {
color: var(--color-text-brand-soft);
}
}
@media (prefers-reduced-motion: reduce) {
.action {
transition: none;
}
}
.close {
color: inherit;
svg {
width: 20px;
height: 20px;
}
}
.variantDefault {
.icon {
background-color: var(--color-bg-brand-soft);
}
}
/* .variantSubtle {
border: 1px solid var(--color-bg-brand-softer);
background-color: var(--color-bg-primary);
.icon {
background-color: var(--color-bg-brand-softer);
}
} */
.variantFeature {
background-color: var(--color-bg-brand-base);
color: var(--color-text-on-brand-base);
button:hover {
color: color-mix(var(--color-text-on-brand-base), transparent 20%);
}
}
.variantInverted {
background-color: var(--color-bg-inverted);
color: var(--color-text-on-inverted);
}
.variantSuccess {
background-color: var(--color-bg-success-softer);
.icon {
background-color: var(--color-bg-success-soft);
}
}
.variantWarning {
background-color: var(--color-bg-warning-softer);
.icon {
background-color: var(--color-bg-warning-soft);
}
}
.variantError {
background-color: var(--color-bg-error-softer);
.icon {
background-color: var(--color-bg-error-soft);
}
}

View File

@ -0,0 +1,4 @@
export { TextInputField } from './text_input_field';
export { TextAreaField } from './text_area_field';
export { ToggleField, PlainToggleField } from './toggle_field';
export { SelectField } from './select_field';

View File

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SelectField } from './select_field';
const meta = {
title: 'Components/Form Fields/SelectField',
component: SelectField,
args: {
label: 'Fruit preference',
hint: 'Select your favourite fruit or not. Up to you.',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<SelectField {...args}>
<option>Apple</option>
<option>Banana</option>
<option>Kiwi</option>
<option>Lemon</option>
<option>Mango</option>
<option>Orange</option>
<option>Pomelo</option>
<option>Strawberries</option>
<option>Something else</option>
</SelectField>
</div>
);
},
} satisfies Meta<typeof SelectField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,38 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-item selections.
* Provide selectable items via nested `<option>` elements.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<div className='select-wrapper'>
<select {...otherProps} {...inputProps} ref={ref}>
{children}
</select>
</div>
)}
</FormFieldWrapper>
),
);
SelectField.displayName = 'SelectField';

View File

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextAreaField } from './text_area_field';
const meta = {
title: 'Components/Form Fields/TextAreaField',
component: TextAreaField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextAreaField {...args} />
</div>
);
},
} satisfies Meta<typeof TextAreaField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,31 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
/**
* A simple form field for multi-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextAreaField.displayName = 'TextAreaField';

View File

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextInputField } from './text_input_field';
const meta = {
title: 'Components/Form Fields/TextInputField',
component: TextInputField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<TextInputField {...args} />
</div>
);
},
} satisfies Meta<typeof TextInputField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};

View File

@ -0,0 +1,36 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper';
import type { CommonFieldWrapperProps } from './wrapper';
interface Props
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<input type={type} {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
),
);
TextInputField.displayName = 'TextInputField';

View File

@ -0,0 +1,75 @@
.input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
z-index: 1;
}
.toggle {
--diameter: 20px;
--padding: 2px;
--transition: 0.2s ease-in-out;
display: inline-flex;
align-items: center;
border-radius: 9999px;
width: calc(var(--diameter) * 2);
background: var(--color-bg-tertiary);
padding: var(--padding);
transition: background var(--transition);
box-sizing: border-box;
}
.toggle::before {
content: '';
height: var(--diameter);
width: var(--diameter);
border-radius: 9999px;
background: var(--color-text-on-brand-base);
box-shadow: 0 2px 4px 0 color-mix(var(--color-black), transparent 75%);
transition: transform var(--transition);
}
@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle::before {
transition: none;
}
}
.input:checked + .toggle {
background: var(--color-bg-brand-base);
}
.input:checked:is(:hover, :focus) + .toggle {
background: var(--color-bg-brand-base-hover);
}
.input:focus-visible + .toggle {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
.input:checked + .toggle::before {
transform: translateX(calc(var(--diameter) - (var(--padding) * 2)));
}
.input:disabled {
cursor: not-allowed;
}
.input:disabled + .toggle {
opacity: 0.6;
}
:global([dir='rtl']) .input:checked + .toggle::before {
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
}
.wrapper {
display: inline-block;
position: relative;
}

View File

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { PlainToggleField, ToggleField } from './toggle_field';
const meta = {
title: 'Components/Form Fields/ToggleField',
component: ToggleField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
disabled: false,
size: 20,
},
argTypes: {
size: {
control: { type: 'range', min: 10, max: 40, step: 1 },
},
},
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<ToggleField {...args} />
</div>
);
},
} satisfies Meta<typeof ToggleField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Required: Story = {
args: {
required: true,
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
export const Disabled: Story = {
args: {
disabled: true,
checked: true,
},
};
export const Plain: Story = {
render(props) {
return <PlainToggleField {...props} />;
},
};
export const Small: Story = {
args: {
size: 12,
},
};
export const Large: Story = {
args: {
size: 36,
},
};

View File

@ -0,0 +1,52 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import classes from './toggle.module.css';
import type { CommonFieldWrapperProps } from './wrapper';
import { FormFieldWrapper } from './wrapper';
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number;
};
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<PlainToggleField {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
));
ToggleField.displayName = 'ToggleField';
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => (
<span className={classes.wrapper}>
<input
{...otherProps}
type='checkbox'
className={classes.input}
ref={ref}
/>
<span
className={classNames(classes.toggle, className)}
style={
{ '--diameter': size ? `${size}px` : undefined } as CSSProperties
}
hidden
/>
</span>
),
);
PlainToggleField.displayName = 'PlainToggleField';

View File

@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ReactNode, FC } from 'react';
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface InputProps {
id: string;
required?: boolean;
'aria-describedby'?: string;
}
interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
inputId?: string;
children: (inputProps: InputProps) => ReactNode;
}
/**
* These types can be extended when creating individual field components.
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
>;
/**
* A simple form field wrapper for adding a label and hint to enclosed components.
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputId: inputIdProp,
label,
hint,
required,
hasError,
children,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const inputProps: InputProps = {
required,
id: inputId,
};
if (hasHint) {
inputProps['aria-describedby'] = hintId;
}
return (
<div
className={classNames('input with_block_label', {
field_with_errors: hasError,
})}
>
<div className='label_input'>
<label htmlFor={inputId}>
{label}
{required !== undefined && <RequiredMark required={required} />}
</label>
{hasHint && (
<span className='hint' id={hintId}>
{hint}
</span>
)}
<div className='label_input__wrapper'>{children(inputProps)}</div>
</div>
</div>
);
};
/**
* If `required` is explicitly set to `false` rather than `undefined`,
* the field will be visually marked as "optional".
*/
const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
required ? (
<>
{' '}
<abbr aria-hidden='true'>*</abbr>
</>
) : (
<>
{' '}
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);

View File

@ -13,6 +13,7 @@ interface Props extends React.SVGProps<SVGSVGElement> {
children?: never;
id: string;
icon: IconProp;
noFill?: boolean;
}
export const Icon: React.FC<Props> = ({
@ -20,6 +21,7 @@ export const Icon: React.FC<Props> = ({
icon: IconComponent,
className,
'aria-label': ariaLabel,
noFill = false,
...other
}) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -42,7 +44,12 @@ export const Icon: React.FC<Props> = ({
return (
<IconComponent
className={classNames('icon', `icon-${id}`, className)}
className={classNames(
'icon',
`icon-${id}`,
noFill && 'icon--no-fill',
className,
)}
title={title}
aria-hidden={ariaHidden}
aria-label={ariaLabel}

View File

@ -0,0 +1,33 @@
import type { FC, ReactNode } from 'react';
import classNames from 'classnames';
import classes from './styles.module.css';
export interface MiniCardProps {
label: ReactNode;
value: ReactNode;
className?: string;
hidden?: boolean;
}
export const MiniCard: FC<MiniCardProps> = ({
label,
value,
className,
hidden,
}) => {
if (!label) {
return null;
}
return (
<div
className={classNames(classes.card, className)}
inert={hidden ? '' : undefined}
>
<dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd>
</div>
);
};

View File

@ -0,0 +1,69 @@
import type { FC, Key, MouseEventHandler } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useOverflow } from '@/mastodon/hooks/useOverflow';
import { MiniCard } from '.';
import type { MiniCardProps } from '.';
import classes from './styles.module.css';
interface MiniCardListProps {
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & {
key?: Key;
})[];
className?: string;
onOverflowClick?: MouseEventHandler;
}
export const MiniCardList: FC<MiniCardListProps> = ({
cards = [],
className,
onOverflowClick,
}) => {
const {
wrapperRef,
listRef,
hiddenCount,
hasOverflow,
hiddenIndex,
maxWidth,
} = useOverflow();
if (!cards.length) {
return null;
}
return (
<div className={classNames(classes.wrapper, className)} ref={wrapperRef}>
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
{cards.map((card, index) => (
<MiniCard
key={card.key ?? index}
label={card.label}
value={card.value}
hidden={hasOverflow && index >= hiddenIndex}
className={card.className}
/>
))}
</dl>
{cards.length > 1 && (
<div>
<button
type='button'
className={classNames(classes.more, !hasOverflow && classes.hidden)}
onClick={onOverflowClick}
>
<FormattedMessage
id='minicard.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { MiniCardList } from './list';
const meta = {
title: 'Components/MiniCard',
component: MiniCardList,
args: {
onOverflowClick: action('Overflow clicked'),
},
render(args) {
return (
<div
style={{
resize: 'horizontal',
padding: '1rem',
border: '1px solid gray',
overflow: 'auto',
width: '400px',
minWidth: '100px',
}}
>
<MiniCardList {...args} />
</div>
);
},
} satisfies Meta<typeof MiniCardList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
cards: [
{ label: 'Pronouns', value: 'they/them' },
{
label: 'Website',
value: <a href='https://example.com'>bowie-the-db.meow</a>,
},
{
label: 'Free playlists',
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
},
{ label: 'Location', value: 'Purris, France' },
],
},
};
export const LongValue: Story = {
args: {
cards: [
{
label: 'Username',
value: 'bowie-the-dj',
},
{
label: 'Bio',
value:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
],
},
};
export const OneCard: Story = {
args: {
cards: [{ label: 'Pronouns', value: 'they/them' }],
},
};

View File

@ -0,0 +1,57 @@
.wrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 4px;
}
.list {
display: flex;
gap: 4px;
overflow: hidden;
position: relative;
}
.card,
.more {
border: 1px solid var(--color-border-primary);
padding: 8px;
border-radius: 8px;
flex-shrink: 0;
}
.more {
color: var(--color-text-secondary);
font-weight: 600;
appearance: none;
background: none;
aspect-ratio: 1;
height: 100%;
transition: all 300ms linear;
}
.more:hover {
background-color: var(--color-bg-brand-softer);
color: var(--color-text-primary);
}
.hidden {
display: none;
}
.label {
color: var(--color-text-secondary);
margin-bottom: 2px;
}
.value {
color: var(--color-text-primary);
font-weight: 600;
}
.label,
.value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
'onQuoteCancel',
];
state = {

View File

@ -0,0 +1,50 @@
.tag {
border-radius: 9999px;
border: 1px solid var(--color-border-primary);
appearance: none;
background: none;
padding: 6px 8px;
transition: all 0.2s ease-in-out;
color: var(--color-text-primary);
display: inline-flex;
align-items: center;
gap: 2px;
}
button.tag:hover,
button.tag:focus {
border-color: var(--color-bg-brand-base-hover);
}
button.tag:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
.active {
border-color: var(--color-text-brand);
background: var(--color-bg-brand-softer);
color: var(--color-text-brand);
}
.icon {
height: 1.2em;
width: 1.2em;
}
.closeButton {
svg {
height: 1.2em;
width: 1.2em;
}
}
.active .closeButton {
color: inherit;
}
.tagsWrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
import { EditableTag, Tag } from './tag';
const meta = {
component: Tag,
title: 'Components/Tags/Single Tag',
args: {
name: 'example-tag',
active: false,
onClick: action('Click'),
},
} satisfies Meta<typeof Tag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithIcon: Story = {
args: {
icon: MusicNoteIcon,
},
};
export const Editable: Story = {
render(args) {
return <EditableTag {...args} onRemove={action('Remove')} />;
},
};
export const EditableWithIcon: Story = {
render(args) {
return (
<EditableTag
{...args}
removeIcon={MusicNoteIcon}
onRemove={action('Remove')}
/>
);
},
};

View File

@ -0,0 +1,99 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { forwardRef } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import type { OmitUnion } from '@/mastodon/utils/types';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import type { IconProp } from '../icon';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import classes from './style.module.css';
export interface TagProps {
name: ReactNode;
active?: boolean;
icon?: IconProp;
className?: string;
children?: ReactNode;
}
export const Tag = forwardRef<
HTMLButtonElement,
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
>(({ name, active, icon, className, children, ...props }, ref) => {
if (!name) {
return null;
}
return (
<button
{...props}
type='button'
ref={ref}
className={classNames(className, classes.tag, active && classes.active)}
aria-pressed={active}
>
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
{typeof name === 'string' ? `#${name}` : name}
{children}
</button>
);
});
Tag.displayName = 'Tag';
export const EditableTag = forwardRef<
HTMLSpanElement,
OmitUnion<
ComponentPropsWithoutRef<'span'>,
TagProps & {
onRemove: () => void;
removeIcon?: IconProp;
}
>
>(
(
{
name,
active,
icon,
className,
children,
removeIcon = CloseIcon,
onRemove,
...props
},
ref,
) => {
const intl = useIntl();
if (!name) {
return null;
}
return (
<span
{...props}
ref={ref}
className={classNames(className, classes.tag, active && classes.active)}
>
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
{typeof name === 'string' ? `#${name}` : name}
{children}
<IconButton
className={classes.closeButton}
iconComponent={removeIcon}
onClick={onRemove}
icon='remove'
title={intl.formatMessage({
id: 'tag.remove',
defaultMessage: 'Remove',
})}
/>
</span>
);
},
);
EditableTag.displayName = 'EditableTag';

View File

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Tags } from './tags';
const meta = {
component: Tags,
title: 'Components/Tags/List',
args: {
tags: [{ name: 'tag-one' }, { name: 'tag-two' }],
active: 'tag-one',
},
} satisfies Meta<typeof Tags>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render(args) {
return <Tags {...args} />;
},
};
export const Editable: Story = {
args: {
onRemove: action('Remove'),
},
};

View File

@ -0,0 +1,64 @@
import { forwardRef, useCallback } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
import classes from './style.module.css';
import { EditableTag, Tag } from './tag';
import type { TagProps } from './tag';
type Tag = TagProps & { name: string };
export type TagsProps = {
tags: Tag[];
active?: string;
} & (
| ({
onRemove?: never;
} & ComponentPropsWithoutRef<'button'>)
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
);
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
({ tags, active, onRemove, className, ...props }, ref) => {
if (onRemove) {
return (
<div className={classNames(classes.tagsWrapper, className)}>
{tags.map((tag) => (
<MappedTag
key={tag.name}
active={tag.name === active}
onRemove={onRemove}
{...tag}
{...props}
/>
))}
</div>
);
}
return (
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
{tags.map((tag) => (
<Tag
key={tag.name}
active={tag.name === active}
{...tag}
{...props}
/>
))}
</div>
);
},
);
Tags.displayName = 'Tags';
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
onRemove,
...props
}) => {
const handleRemove = useCallback(() => {
onRemove?.(props.name);
}, [onRemove, props.name]);
return <EditableTag {...props} onRemove={handleRemove} />;
};

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
@ -15,7 +16,9 @@ export const DomainPill: React.FC<{
domain: string;
username: string;
isSelf: boolean;
}> = ({ domain, username, isSelf }) => {
children?: ReactNode;
className?: string;
}> = ({ domain, username, isSelf, children, className }) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
@ -32,14 +35,16 @@ export const DomainPill: React.FC<{
return (
<>
<button
className={classNames('account__domain-pill', { active: open })}
className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
type='button'
>
{domain}
{children ?? domain}
</button>
<Overlay

View File

@ -0,0 +1,5 @@
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
export function isRedesignEnabled() {
return isClientFeatureEnabled('profile_redesign');
}

View File

@ -1,177 +1,33 @@
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useCallback } from 'react';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import { AccountFields } from '@/mastodon/components/account_fields';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import {
followAccount,
unblockAccount,
unmuteAccount,
pinAccount,
unpinAccount,
removeAccountFromFollowers,
} from 'mastodon/actions/accounts';
import { initBlockModal } from 'mastodon/actions/blocks';
import { mentionCompose, directCompose } from 'mastodon/actions/compose';
import {
initDomainBlockModal,
unblockDomain,
} from 'mastodon/actions/domain_blocks';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import {
FollowersCounter,
FollowingCounter,
StatusesCounter,
} from 'mastodon/components/counters';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { FollowButton } from 'mastodon/components/follow_button';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { ShortNumber } from 'mastodon/components/short_number';
import { AccountNote } from 'mastodon/features/account/components/account_note';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import {
PERMISSION_MANAGE_USERS,
PERMISSION_MANAGE_FEDERATION,
} from 'mastodon/permissions';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { isRedesignEnabled } from '../common';
import { AccountName } from './account_name';
import { AccountBadges } from './badges';
import { AccountButtons } from './buttons';
import { FamiliarFollowers } from './familiar_followers';
import { AccountHeaderFields } from './fields';
import { AccountInfo } from './info';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: {
id: 'account.link_verified_on',
defaultMessage: 'Ownership of this link was checked on {date}',
},
account_locked: {
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
},
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
media: { id: 'account.media', defaultMessage: 'Media' },
blockDomain: {
id: 'account.block_domain',
defaultMessage: 'Block domain {domain}',
},
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
hideReblogs: {
id: 'account.hide_reblogs',
defaultMessage: 'Hide boosts from @{name}',
},
showReblogs: {
id: 'account.show_reblogs',
defaultMessage: 'Show boosts from @{name}',
},
enableNotifications: {
id: 'account.enable_notifications',
defaultMessage: 'Notify me when @{name} posts',
},
disableNotifications: {
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
},
follow_requests: {
id: 'navigation_bar.follow_requests',
defaultMessage: 'Follow requests',
},
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: {
id: 'navigation_bar.followed_tags',
defaultMessage: 'Followed hashtags',
},
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: {
id: 'navigation_bar.domain_blocks',
defaultMessage: 'Blocked domains',
},
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: {
id: 'account.unendorse',
defaultMessage: "Don't feature on profile",
},
add_or_remove_from_list: {
id: 'account.add_or_remove_from_list',
defaultMessage: 'Add or Remove from lists',
},
admin_account: {
id: 'status.admin_account',
defaultMessage: 'Open moderation interface for @{name}',
},
admin_domain: {
id: 'status.admin_domain',
defaultMessage: 'Open moderation interface for {domain}',
},
languages: {
id: 'account.languages',
defaultMessage: 'Change subscribed languages',
},
openOriginalPage: {
id: 'account.open_original_page',
defaultMessage: 'Open original page',
},
removeFromFollowers: {
id: 'account.remove_from_followers',
defaultMessage: 'Remove {name} from followers',
},
confirmRemoveFromFollowersTitle: {
id: 'confirmations.remove_from_followers.title',
defaultMessage: 'Remove follower?',
},
confirmRemoveFromFollowersMessage: {
id: 'confirmations.remove_from_followers.message',
defaultMessage:
'{name} will stop following you. Are you sure you want to proceed?',
},
confirmRemoveFromFollowersButton: {
id: 'confirmations.remove_from_followers.confirm',
defaultMessage: 'Remove follower',
},
});
import { AccountNote as AccountNoteRedesign } from './note';
import { AccountNumberFields } from './number_fields';
import redesignClasses from './redesign.module.scss';
import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => {
const displayName = account.display_name;
@ -190,150 +46,12 @@ export const AccountHeader: React.FC<{
hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleBlock = useCallback(() => {
if (!account) {
return;
}
if (relationship?.blocking) {
dispatch(unblockAccount(account.id));
} else {
dispatch(initBlockModal(account));
}
}, [dispatch, account, relationship]);
const handleMention = useCallback(() => {
if (!account) {
return;
}
dispatch(mentionCompose(account));
}, [dispatch, account]);
const handleDirect = useCallback(() => {
if (!account) {
return;
}
dispatch(directCompose(account));
}, [dispatch, account]);
const handleReport = useCallback(() => {
if (!account) {
return;
}
dispatch(initReport(account));
}, [dispatch, account]);
const handleReblogToggle = useCallback(() => {
if (!account) {
return;
}
if (relationship?.showing_reblogs) {
dispatch(followAccount(account.id, { reblogs: false }));
} else {
dispatch(followAccount(account.id, { reblogs: true }));
}
}, [dispatch, account, relationship]);
const handleNotifyToggle = useCallback(() => {
if (!account) {
return;
}
if (relationship?.notifying) {
dispatch(followAccount(account.id, { notify: false }));
} else {
dispatch(followAccount(account.id, { notify: true }));
}
}, [dispatch, account, relationship]);
const handleMute = useCallback(() => {
if (!account) {
return;
}
if (relationship?.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
}, [dispatch, account, relationship]);
const handleBlockDomain = useCallback(() => {
if (!account) {
return;
}
dispatch(initDomainBlockModal(account));
}, [dispatch, account]);
const handleUnblockDomain = useCallback(() => {
if (!account) {
return;
}
const domain = account.acct.split('@')[1];
if (!domain) {
return;
}
dispatch(unblockDomain(domain));
}, [dispatch, account]);
const handleEndorseToggle = useCallback(() => {
if (!account) {
return;
}
if (relationship?.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
}, [dispatch, account, relationship]);
const handleAddToList = useCallback(() => {
if (!account) {
return;
}
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
}, [dispatch, account]);
const handleChangeLanguages = useCallback(() => {
if (!account) {
return;
}
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
}, [dispatch, account]);
const handleOpenAvatar = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
@ -359,410 +77,12 @@ export const AccountHeader: React.FC<{
[dispatch, account],
);
const handleShare = useCallback(() => {
if (!account) {
return;
}
void navigator.share({
url: account.url,
});
}, [account]);
const suspended = account?.suspended;
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
return arr;
}
if (signedIn && !account.suspended) {
arr.push({
text: intl.formatMessage(messages.mention, {
name: account.username,
}),
action: handleMention,
});
arr.push({
text: intl.formatMessage(messages.direct, {
name: account.username,
}),
action: handleDirect,
});
arr.push(null);
}
if (isRemote) {
arr.push({
text: intl.formatMessage(messages.openOriginalPage),
href: account.url,
});
arr.push(null);
}
if (signedIn) {
if (relationship?.following) {
if (!relationship.muting) {
if (relationship.showing_reblogs) {
arr.push({
text: intl.formatMessage(messages.hideReblogs, {
name: account.username,
}),
action: handleReblogToggle,
});
} else {
arr.push({
text: intl.formatMessage(messages.showReblogs, {
name: account.username,
}),
action: handleReblogToggle,
});
}
arr.push({
text: intl.formatMessage(messages.languages),
action: handleChangeLanguages,
});
arr.push(null);
}
arr.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: handleEndorseToggle,
});
arr.push({
text: intl.formatMessage(messages.add_or_remove_from_list),
action: handleAddToList,
});
arr.push(null);
}
if (relationship?.followed_by) {
const handleRemoveFromFollowers = () => {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(
messages.confirmRemoveFromFollowersTitle,
),
message: intl.formatMessage(
messages.confirmRemoveFromFollowersMessage,
{ name: <strong>{account.acct}</strong> },
),
confirm: intl.formatMessage(
messages.confirmRemoveFromFollowersButton,
),
onConfirm: () => {
void dispatch(removeAccountFromFollowers({ accountId }));
},
},
}),
);
};
arr.push({
text: intl.formatMessage(messages.removeFromFollowers, {
name: account.username,
}),
action: handleRemoveFromFollowers,
dangerous: true,
});
}
if (relationship?.muting) {
arr.push({
text: intl.formatMessage(messages.unmute, {
name: account.username,
}),
action: handleMute,
});
} else {
arr.push({
text: intl.formatMessage(messages.mute, {
name: account.username,
}),
action: handleMute,
dangerous: true,
});
}
if (relationship?.blocking) {
arr.push({
text: intl.formatMessage(messages.unblock, {
name: account.username,
}),
action: handleBlock,
});
} else {
arr.push({
text: intl.formatMessage(messages.block, {
name: account.username,
}),
action: handleBlock,
dangerous: true,
});
}
if (!account.suspended) {
arr.push({
text: intl.formatMessage(messages.report, {
name: account.username,
}),
action: handleReport,
dangerous: true,
});
}
}
if (signedIn && isRemote) {
arr.push(null);
if (relationship?.domain_blocking) {
arr.push({
text: intl.formatMessage(messages.unblockDomain, {
domain: remoteDomain,
}),
action: handleUnblockDomain,
});
} else {
arr.push({
text: intl.formatMessage(messages.blockDomain, {
domain: remoteDomain,
}),
action: handleBlockDomain,
dangerous: true,
});
}
}
if (
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
(isRemote &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION)
) {
arr.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
arr.push({
text: intl.formatMessage(messages.admin_account, {
name: account.username,
}),
href: `/admin/accounts/${account.id}`,
});
}
if (
isRemote &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION
) {
arr.push({
text: intl.formatMessage(messages.admin_domain, {
domain: remoteDomain,
}),
href: `/admin/instances/${remoteDomain}`,
});
}
}
return arr;
}, [
dispatch,
accountId,
account,
relationship,
permissions,
isRemote,
remoteDomain,
intl,
signedIn,
handleAddToList,
handleBlock,
handleBlockDomain,
handleChangeLanguages,
handleDirect,
handleEndorseToggle,
handleMention,
handleMute,
handleReblogToggle,
handleReport,
handleUnblockDomain,
]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) {
return null;
}
let actionBtn: React.ReactNode,
bellBtn: React.ReactNode,
lockedIcon: React.ReactNode,
shareBtn: React.ReactNode;
const info: React.ReactNode[] = [];
if (me !== account.id && relationship) {
if (
relationship.followed_by &&
(relationship.following || relationship.requested)
) {
info.push(
<span key='mutual' className='relationship-tag'>
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
</span>,
);
} else if (relationship.followed_by) {
info.push(
<span key='followed_by' className='relationship-tag'>
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
</span>,
);
} else if (relationship.requested_by) {
info.push(
<span key='requested_by' className='relationship-tag'>
<FormattedMessage
id='account.requests_to_follow_you'
defaultMessage='Requests to follow you'
/>
</span>,
);
}
if (relationship.blocking) {
info.push(
<span key='blocking' className='relationship-tag'>
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
</span>,
);
}
if (relationship.muting) {
info.push(
<span key='muting' className='relationship-tag'>
<FormattedMessage id='account.muting' defaultMessage='Muting' />
</span>,
);
}
if (relationship.domain_blocking) {
info.push(
<span key='domain_blocking' className='relationship-tag'>
<FormattedMessage
id='account.domain_blocking'
defaultMessage='Blocking domain'
/>
</span>,
);
}
}
if (relationship?.requested || relationship?.following) {
bellBtn = (
<IconButton
icon={relationship.notifying ? 'bell' : 'bell-o'}
iconComponent={
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
}
active={relationship.notifying}
title={intl.formatMessage(
relationship.notifying
? messages.disableNotifications
: messages.enableNotifications,
{ name: account.username },
)}
onClick={handleNotifyToggle}
/>
);
}
if ('share' in navigator) {
shareBtn = (
<IconButton
className='optional'
icon=''
iconComponent={ShareIcon}
title={intl.formatMessage(messages.share, {
name: account.username,
})}
onClick={handleShare}
/>
);
} else {
shareBtn = (
<CopyIconButton
className='optional'
title={intl.formatMessage(messages.copy)}
value={account.url}
/>
);
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
);
}
if (account.locked) {
lockedIcon = (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage(messages.account_locked)}
/>
);
}
const fields = account.fields;
const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@');
const username = account.acct.split('@')[0];
const domain = isLocal ? localDomain : account.acct.split('@')[1];
const isIndexable = !account.noindex;
const badges = [];
if (account.bot) {
badges.push(<AutomatedBadge key='bot-badge' />);
} else if (account.group) {
badges.push(<GroupBadge key='group-badge' />);
}
account.roles.forEach((role) => {
badges.push(
<Badge
key={`role-badge-${role.get('id')}`}
label={<span>{role.get('name')}</span>}
domain={domain}
roleId={role.get('id')}
/>,
);
});
return (
<div className='account-timeline__header'>
@ -776,15 +96,16 @@ export const AccountHeader: React.FC<{
inactive: !!account.moved,
})}
>
{!(suspended || hidden || account.moved) &&
relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
<div className='account__header__image'>
<div className='account__header__info'>{info}</div>
{me !== account.id && relationship && (
<AccountInfo relationship={relationship} />
)}
{!(suspended || hidden) && (
{!suspendedOrHidden && (
<img
src={autoPlayGif ? account.header : account.header_static}
alt=''
@ -803,148 +124,73 @@ export const AccountHeader: React.FC<{
onClick={handleOpenAvatar}
>
<Avatar
account={suspended || hidden ? undefined : account}
account={suspendedOrHidden ? undefined : account}
size={92}
/>
</a>
<div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{!hidden && shareBtn}
{menu}
</div>
{!isRedesignEnabled() && (
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
/>
)}
</div>
<div className='account__header__tabs__name'>
<h1>
<DisplayName account={account} variant='simple' />
<small>
<span>
@{username}
<span className='invisible'>@{domain}</span>
</span>
<DomainPill
username={username ?? ''}
domain={domain ?? ''}
isSelf={me === account.id}
/>
{lockedIcon}
</small>
</h1>
<div
className={classNames(
'account__header__tabs__name',
isRedesignEnabled() && redesignClasses.nameWrapper,
)}
>
<AccountName accountId={accountId} />
{isRedesignEnabled() && <AccountButtons accountId={accountId} />}
</div>
{badges.length > 0 && (
<div className='account__header__badges'>{badges}</div>
)}
<AccountBadges accountId={accountId} />
{account.id !== me && signedIn && !(suspended || hidden) && (
{me && account.id !== me && !suspendedOrHidden && (
<FamiliarFollowers accountId={accountId} />
)}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
<AccountButtons
className='account__header__buttons--mobile'
accountId={accountId}
noShare
/>
{!(suspended || hidden) && (
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{account.id !== me && signedIn && (
<AccountNote accountId={accountId} />
)}
{me &&
account.id !== me &&
(isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
))}
<AccountBio
accountId={accountId}
className='account__header__content'
/>
<div className='account__header__fields'>
<dl>
<dt>
<FormattedMessage
id='account.joined_short'
defaultMessage='Joined'
/>
</dt>
<dd>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</dd>
</dl>
<AccountFields fields={fields} emojis={account.emojis} />
</div>
<AccountHeaderFields accountId={accountId} />
</div>
<div className='account__header__extra__links'>
<NavLink
to={`/@${account.acct}`}
title={intl.formatNumber(account.statuses_count)}
>
<ShortNumber
value={account.statuses_count}
renderer={StatusesCounter}
/>
</NavLink>
<NavLink
exact
to={`/@${account.acct}/following`}
title={intl.formatNumber(account.following_count)}
>
<ShortNumber
value={account.following_count}
renderer={FollowingCounter}
/>
</NavLink>
<NavLink
exact
to={`/@${account.acct}/followers`}
title={intl.formatNumber(account.followers_count)}
>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>
</NavLink>
</div>
<AccountNumberFields accountId={accountId} />
</div>
)}
</div>
</AnimateEmojiProvider>
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
<NavLink exact to={`/@${account.acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<NavLink exact to={`/@${account.acct}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</NavLink>
<NavLink exact to={`/@${account.acct}/with_replies`}>
<FormattedMessage
id='account.posts_with_replies'
defaultMessage='Posts and replies'
/>
</NavLink>
<NavLink exact to={`/@${account.acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
</div>
)}
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta
name='robots'
content={isLocal && isIndexable ? 'all' : 'noindex'}
content={isLocal && !account.noindex ? 'all' : 'noindex'}
/>
<link rel='canonical' href={account.url} />
</Helmet>

View File

@ -0,0 +1,79 @@
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import { DisplayName } from '@/mastodon/components/display_name';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAppSelector } from '@/mastodon/store';
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { DomainPill } from '../../account/components/domain_pill';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
const me = useAppSelector((state) => state.meta.get('me') as string);
const localDomain = useAppSelector(
(state) => state.meta.get('domain') as string,
);
if (!account) {
return null;
}
const [username = '', domain = localDomain] = account.acct.split('@');
if (!isRedesignEnabled()) {
return (
<h1>
<DisplayName account={account} variant='simple' />
<small>
<span>
@{username}
<span className='invisible'>@{domain}</span>
</span>
<DomainPill
username={username}
domain={domain}
isSelf={me === account.id}
/>
{account.locked && (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
/>
)}
</small>
</h1>
);
}
return (
<div className={classes.name}>
<h1>
<DisplayName account={account} variant='simple' />
</h1>
<p className={classes.username}>
@{username}@{domain}
<DomainPill
username={username}
domain={domain}
isSelf={me === account.id}
className={classes.domainPill}
>
<Icon id='help' icon={HelpIcon} />
</DomainPill>
</p>
</div>
);
};

View File

@ -0,0 +1,70 @@
import type { FC, ReactNode } from 'react';
import IconAdmin from '@/images/icons/icon_admin.svg?react';
import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
import { useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
const account = useAccount(accountId);
const localDomain = useAppSelector(
(state) => state.meta.get('domain') as string,
);
const badges = [];
if (!account) {
return null;
}
const className = isRedesignEnabled() ? classes.badge : '';
if (account.bot) {
badges.push(<AutomatedBadge key='bot-badge' className={className} />);
} else if (account.group) {
badges.push(<GroupBadge key='group-badge' className={className} />);
}
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
: localDomain;
account.roles.forEach((role) => {
let icon: ReactNode = undefined;
if (isAdminBadge(role)) {
icon = (
<Icon
icon={IconAdmin}
id='badge-admin'
className={classes.badgeIcon}
noFill
/>
);
}
badges.push(
<Badge
key={role.id}
label={role.name}
className={className}
domain={isRedesignEnabled() ? `(${domain})` : domain}
roleId={role.id}
icon={icon}
/>,
);
});
if (!badges.length) {
return null;
}
return <div className={'account__header__badges'}>{badges}</div>;
};
function isAdminBadge(role: AccountRole) {
const name = role.name.toLowerCase();
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
}

View File

@ -0,0 +1,134 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { followAccount } from '@/mastodon/actions/accounts';
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
import { FollowButton } from '@/mastodon/components/follow_button';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { AccountMenu } from './menu';
const messages = defineMessages({
enableNotifications: {
id: 'account.enable_notifications',
defaultMessage: 'Notify me when @{name} posts',
},
disableNotifications: {
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
});
interface AccountButtonsProps {
accountId: string;
className?: string;
noShare?: boolean;
}
export const AccountButtons: FC<AccountButtonsProps> = ({
accountId,
className,
noShare,
}) => {
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const me = useAppSelector((state) => state.meta.get('me') as string);
return (
<div className={classNames('account__header__buttons', className)}>
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
{accountId !== me && <AccountMenu accountId={accountId} />}
</div>
);
};
const AccountButtonsOther: FC<
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
> = ({ accountId, noShare }) => {
const intl = useIntl();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
const handleNotifyToggle = useCallback(() => {
if (account) {
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
}
}, [dispatch, account, relationship]);
const accountUrl = account?.url;
const handleShare = useCallback(() => {
if (accountUrl) {
void navigator.share({
url: accountUrl,
});
}
}, [accountUrl]);
if (!account) {
return null;
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
const isFollowing = relationship?.requested || relationship?.following;
return (
<>
{!isMovedAndUnfollowedAccount && (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
)}
{isFollowing && (
<IconButton
icon={relationship.notifying ? 'bell' : 'bell-o'}
iconComponent={
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
}
active={relationship.notifying}
title={intl.formatMessage(
relationship.notifying
? messages.disableNotifications
: messages.enableNotifications,
{ name: account.username },
)}
onClick={handleNotifyToggle}
/>
)}
{!noShare &&
('share' in navigator ? (
<IconButton
className='optional'
icon=''
iconComponent={ShareIcon}
title={intl.formatMessage(messages.share, {
name: account.username,
})}
onClick={handleShare}
/>
) : (
<CopyIconButton
className='optional'
title={intl.formatMessage(messages.copy)}
value={account.url}
/>
))}
</>
);
};

View File

@ -0,0 +1,117 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { AccountFields } from '@/mastodon/components/account_fields';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { Icon } from '@/mastodon/components/icon';
import { MiniCardList } from '@/mastodon/components/mini_card/list';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { Account } from '@/mastodon/models/account';
import { useAppDispatch } from '@/mastodon/store';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountHeaderFields: FC<{ accountId: string }> = ({
accountId,
}) => {
const account = useAccount(accountId);
if (!account) {
return null;
}
if (isRedesignEnabled()) {
return <RedesignAccountHeaderFields account={account} />;
}
return (
<div className='account__header__fields'>
<dl>
<dt>
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />
</dt>
<dd>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</dd>
</dl>
<AccountFields fields={account.fields} emojis={account.emojis} />
</div>
);
};
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink();
const cards = useMemo(
() =>
account.fields
.toArray()
.map(({ value_emojified, name_emojified, verified_at }) => ({
label: (
<>
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
{...htmlHandlers}
/>
{!!verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</>
),
value: (
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
),
className: classNames(
classes.fieldCard,
!!verified_at && classes.fieldCardVerified,
),
})),
[account.emojis, account.fields, htmlHandlers],
);
const dispatch = useAppDispatch();
const handleOverflowClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_FIELDS',
modalProps: { accountId: account.id },
}),
);
}, [account.id, dispatch]);
return (
<MiniCardList
cards={cards}
className={classes.fieldList}
onOverflowClick={handleOverflowClick}
/>
);
};

View File

@ -0,0 +1,95 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import classes from './redesign.module.scss';
export const AccountFieldsModal: FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const account = useAccount(accountId);
const htmlHandlers = useElementHandledLink();
if (!account) {
return (
<div className='modal-root__modal dialog-modal'>
<LoadingIndicator />
</div>
);
}
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
icon='close'
className={classes.modalCloseButton}
onClick={onClose}
iconComponent={CloseIcon}
title={intl.formatMessage({
id: 'account_fields_modal.close',
defaultMessage: 'Close',
})}
/>
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
<FormattedMessage
id='account_fields_modal.title'
defaultMessage="{name}'s info"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</span>
</div>
<div className='dialog-modal__content'>
<AnimateEmojiProvider>
<dl className={classes.modalFieldsList}>
{account.fields.map((field, index) => (
<div
key={index}
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
>
<EmojiHTML
as='dt'
htmlString={field.name_emojified}
extraEmojis={account.emojis}
className='translate'
{...htmlHandlers}
/>
<dd>
<EmojiHTML
as='span'
htmlString={field.value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
{!!field.verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</dd>
</div>
))}
</dl>
</AnimateEmojiProvider>
</div>
</div>
);
};

View File

@ -0,0 +1,68 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { Relationship } from '@/mastodon/models/relationship';
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
relationship,
}) => {
if (!relationship) {
return null;
}
return (
<div className='account__header__info'>
{(relationship.followed_by || relationship.requested_by) && (
<span className='relationship-tag'>
<AccountInfoFollower relationship={relationship} />
</span>
)}
{relationship.blocking && (
<span className='relationship-tag'>
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
</span>
)}
{relationship.muting && (
<span key='muting' className='relationship-tag'>
<FormattedMessage id='account.muting' defaultMessage='Muting' />
</span>
)}
{relationship.domain_blocking && (
<span key='domain_blocking' className='relationship-tag'>
<FormattedMessage
id='account.domain_blocking'
defaultMessage='Blocking domain'
/>
</span>
)}
</div>
);
};
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
relationship,
}) => {
if (
relationship.followed_by &&
(relationship.following || relationship.requested)
) {
return (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
);
} else if (relationship.followed_by) {
return (
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
);
} else if (relationship.requested_by) {
return (
<FormattedMessage
id='account.requests_to_follow_you'
defaultMessage='Requests to follow you'
/>
);
}
return null;
};

View File

@ -0,0 +1,406 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
blockAccount,
followAccount,
pinAccount,
unblockAccount,
unmuteAccount,
unpinAccount,
} from '@/mastodon/actions/accounts';
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
import {
initDomainBlockModal,
unblockDomain,
} from '@/mastodon/actions/domain_blocks';
import { openModal } from '@/mastodon/actions/modal';
import { initMuteModal } from '@/mastodon/actions/mutes';
import { initReport } from '@/mastodon/actions/reports';
import { Dropdown } from '@/mastodon/components/dropdown_menu';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useIdentity } from '@/mastodon/identity_context';
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
import {
PERMISSION_MANAGE_FEDERATION,
PERMISSION_MANAGE_USERS,
} from '@/mastodon/permissions';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { isRedesignEnabled } from '../common';
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
blockDomain: {
id: 'account.block_domain',
defaultMessage: 'Block domain {domain}',
},
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
hideReblogs: {
id: 'account.hide_reblogs',
defaultMessage: 'Hide boosts from @{name}',
},
showReblogs: {
id: 'account.show_reblogs',
defaultMessage: 'Show boosts from @{name}',
},
addNote: {
id: 'account.add_note',
defaultMessage: 'Add a personal note',
},
editNote: {
id: 'account.edit_note',
defaultMessage: 'Edit personal note',
},
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: {
id: 'account.unendorse',
defaultMessage: "Don't feature on profile",
},
add_or_remove_from_list: {
id: 'account.add_or_remove_from_list',
defaultMessage: 'Add or Remove from lists',
},
admin_account: {
id: 'status.admin_account',
defaultMessage: 'Open moderation interface for @{name}',
},
admin_domain: {
id: 'status.admin_domain',
defaultMessage: 'Open moderation interface for {domain}',
},
languages: {
id: 'account.languages',
defaultMessage: 'Change subscribed languages',
},
openOriginalPage: {
id: 'account.open_original_page',
defaultMessage: 'Open original page',
},
removeFromFollowers: {
id: 'account.remove_from_followers',
defaultMessage: 'Remove {name} from followers',
},
confirmRemoveFromFollowersTitle: {
id: 'confirmations.remove_from_followers.title',
defaultMessage: 'Remove follower?',
},
confirmRemoveFromFollowersMessage: {
id: 'confirmations.remove_from_followers.message',
defaultMessage:
'{name} will stop following you. Are you sure you want to proceed?',
},
confirmRemoveFromFollowersButton: {
id: 'confirmations.remove_from_followers.confirm',
defaultMessage: 'Remove follower',
},
});
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
return arr;
}
const isRemote = account.acct !== account.username;
if (signedIn && !account.suspended) {
arr.push({
text: intl.formatMessage(messages.mention, {
name: account.username,
}),
action: () => {
dispatch(mentionCompose(account));
},
});
arr.push({
text: intl.formatMessage(messages.direct, {
name: account.username,
}),
action: () => {
dispatch(directCompose(account));
},
});
arr.push(null);
}
if (isRemote) {
arr.push({
text: intl.formatMessage(messages.openOriginalPage),
href: account.url,
});
arr.push(null);
}
if (!signedIn) {
return arr;
}
if (relationship?.following) {
if (!relationship.muting) {
if (relationship.showing_reblogs) {
arr.push({
text: intl.formatMessage(messages.hideReblogs, {
name: account.username,
}),
action: () => {
dispatch(followAccount(account.id, { reblogs: false }));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.showReblogs, {
name: account.username,
}),
action: () => {
dispatch(followAccount(account.id, { reblogs: true }));
},
});
}
arr.push({
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
});
arr.push(null);
}
}
if (isRedesignEnabled()) {
arr.push({
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
});
if (!relationship?.following) {
arr.push(null);
}
}
if (relationship?.following) {
arr.push({
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: () => {
if (relationship.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
},
});
arr.push({
text: intl.formatMessage(messages.add_or_remove_from_list),
action: () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
});
arr.push(null);
}
if (relationship?.followed_by) {
const handleRemoveFromFollowers = () => {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(
messages.confirmRemoveFromFollowersTitle,
),
message: intl.formatMessage(
messages.confirmRemoveFromFollowersMessage,
{ name: <strong>{account.acct}</strong> },
),
confirm: intl.formatMessage(
messages.confirmRemoveFromFollowersButton,
),
onConfirm: () => {
void dispatch(
removeAccountFromFollowers({ accountId: account.id }),
);
},
},
}),
);
};
arr.push({
text: intl.formatMessage(messages.removeFromFollowers, {
name: account.username,
}),
action: handleRemoveFromFollowers,
dangerous: true,
});
}
if (relationship?.muting) {
arr.push({
text: intl.formatMessage(messages.unmute, {
name: account.username,
}),
action: () => {
dispatch(unmuteAccount(account.id));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.mute, {
name: account.username,
}),
action: () => {
dispatch(initMuteModal(account));
},
dangerous: true,
});
}
if (relationship?.blocking) {
arr.push({
text: intl.formatMessage(messages.unblock, {
name: account.username,
}),
action: () => {
dispatch(unblockAccount(account.id));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.block, {
name: account.username,
}),
action: () => {
dispatch(blockAccount(account.id));
},
dangerous: true,
});
}
if (!account.suspended) {
arr.push({
text: intl.formatMessage(messages.report, {
name: account.username,
}),
action: () => {
dispatch(initReport(account));
},
dangerous: true,
});
}
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
if (remoteDomain) {
arr.push(null);
if (relationship?.domain_blocking) {
arr.push({
text: intl.formatMessage(messages.unblockDomain, {
domain: remoteDomain,
}),
action: () => {
dispatch(unblockDomain(remoteDomain));
},
});
} else {
arr.push({
text: intl.formatMessage(messages.blockDomain, {
domain: remoteDomain,
}),
action: () => {
dispatch(initDomainBlockModal(account));
},
dangerous: true,
});
}
}
if (
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
(isRemote &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION)
) {
arr.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
arr.push({
text: intl.formatMessage(messages.admin_account, {
name: account.username,
}),
href: `/admin/accounts/${account.id}`,
});
}
if (
isRemote &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION
) {
arr.push({
text: intl.formatMessage(messages.admin_domain, {
domain: remoteDomain,
}),
href: `/admin/instances/${remoteDomain}`,
});
}
}
return arr;
}, [account, signedIn, permissions, intl, relationship, dispatch]);
return (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
};

View File

@ -0,0 +1,69 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { openModal } from '@/mastodon/actions/modal';
import { Callout } from '@/mastodon/components/callout';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
import classes from './redesign.module.scss';
const messages = defineMessages({
title: {
id: 'account.note.title',
defaultMessage: 'Personal note (visible only to you)',
},
editButton: {
id: 'account.note.edit_button',
defaultMessage: 'Edit',
},
});
export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
const handleEdit = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: { accountId },
}),
);
}, [accountId, dispatch]);
if (!relationship?.note) {
return null;
}
return (
<Callout
icon={false}
title={intl.formatMessage(messages.title)}
className={classes.note}
extraContent={
<IconButton
icon='edit'
iconComponent={EditIcon}
title={intl.formatMessage(messages.editButton)}
className={classes.noteEditButton}
onClick={handleEdit}
/>
}
>
{relationship.note}
</Callout>
);
};

View File

@ -0,0 +1,94 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { NavLink } from 'react-router-dom';
import {
FollowersCounter,
FollowingCounter,
StatusesCounter,
} from '@/mastodon/components/counters';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { ShortNumber } from '@/mastodon/components/short_number';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountNumberFields: FC<{ accountId: string }> = ({
accountId,
}) => {
const intl = useIntl();
const account = useAccount(accountId);
if (!account) {
return null;
}
return (
<div
className={classNames(
'account__header__extra__links',
isRedesignEnabled() && classes.fieldNumbersWrapper,
)}
>
{!isRedesignEnabled() && (
<NavLink
to={`/@${account.acct}`}
title={intl.formatNumber(account.statuses_count)}
>
<ShortNumber
value={account.statuses_count}
renderer={StatusesCounter}
/>
</NavLink>
)}
<NavLink
exact
to={`/@${account.acct}/following`}
title={intl.formatNumber(account.following_count)}
>
<ShortNumber
value={account.following_count}
renderer={FollowingCounter}
/>
</NavLink>
<NavLink
exact
to={`/@${account.acct}/followers`}
title={intl.formatNumber(account.followers_count)}
>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>
</NavLink>
{isRedesignEnabled() && (
<NavLink exact to={`/@${account.acct}`}>
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
</NavLink>
)}
</div>
);
};

View File

@ -0,0 +1,179 @@
.nameWrapper {
display: flex;
gap: 16px;
}
.name {
flex-grow: 1;
font-size: 22px;
white-space: initial;
line-height: normal;
}
.username {
display: flex;
font-size: 13px;
color: var(--color-text-secondary);
align-items: center;
user-select: all;
margin-top: 4px;
}
.domainPill {
appearance: none;
border: none;
background: none;
padding: 0;
text-decoration: underline;
color: inherit;
font-size: 1em;
font-weight: initial;
margin-left: 2px;
width: 16px;
height: 16px;
transition: color 0.2s ease-in-out;
> svg {
width: 100%;
height: 100%;
}
&:hover,
&:global(.active) {
background: none;
color: var(--color-text-brand-soft);
}
}
.badge {
background-color: var(--color-bg-secondary);
border: none;
color: var(--color-text-secondary);
font-weight: 500;
> span {
font-weight: unset;
opacity: 1;
}
}
svg.badgeIcon {
opacity: 1;
}
.note {
margin-bottom: 16px;
}
.noteEditButton {
color: inherit;
svg {
width: 20px;
height: 20px;
}
}
.fieldList {
margin-top: 16px;
}
.fieldCard {
position: relative;
a {
color: var(--color-text-brand);
text-decoration: none;
}
}
.fieldCardVerified {
background-color: var(--color-bg-brand-softer);
dt {
padding-right: 1rem;
}
.fieldIconVerified {
position: absolute;
top: 4px;
right: 4px;
}
}
.fieldIconVerified {
width: 1rem;
height: 1rem;
}
.fieldNumbersWrapper {
a {
font-weight: unset;
}
}
.modalCloseButton {
padding: 8px;
border-radius: 50%;
border: 1px solid var(--color-border-primary);
}
.modalTitle {
flex-grow: 1;
text-align: center;
}
.modalFieldsList {
padding: 24px;
}
.modalFieldItem {
&:not(:first-child) {
padding-top: 12px;
}
&:not(:last-child)::after {
content: '';
display: block;
border-bottom: 1px solid var(--color-border-primary);
margin-top: 12px;
}
dt {
color: var(--color-text-secondary);
font-size: 13px;
}
dd {
font-weight: 500;
font-size: 15px;
}
.fieldIconVerified {
vertical-align: middle;
margin-left: 4px;
}
}
.tabs {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 24px;
a {
display: block;
font-size: 15px;
font-weight: 500;
padding: 18px 4px;
text-decoration: none;
color: var(--color-text-primary);
border-radius: 0;
}
:global(.active) {
color: var(--color-text-brand);
border-bottom: 4px solid var(--color-text-brand);
padding-bottom: 14px;
}
}

View File

@ -0,0 +1,51 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
if (isRedesignEnabled()) {
return (
<div className={classes.tabs}>
<NavLink isActive={isActive} to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
<NavLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
</div>
);
}
return (
<div className='account__section-headline'>
<NavLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
<NavLink exact to={`/@${acct}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</NavLink>
<NavLink exact to={`/@${acct}/with_replies`}>
<FormattedMessage
id='account.posts_with_replies'
defaultMessage='Posts and replies'
/>
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
</div>
);
};
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
match?.url === location.pathname ||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));

View File

@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
export function useFilters() {
const [boosts, setBoosts] = useSearchParam('boosts');
const [replies, setReplies] = useSearchParam('replies');
const handleSetBoosts = useCallback(
(value: boolean) => {
setBoosts(value ? '1' : null);
},
[setBoosts],
);
const handleSetReplies = useCallback(
(value: boolean) => {
setReplies(value ? '1' : null);
},
[setReplies],
);
return {
boosts: boosts === '1',
replies: replies === '1',
setBoosts: handleSetBoosts,
setReplies: handleSetReplies,
};
}

View File

@ -0,0 +1,21 @@
.noteCallout {
margin-bottom: 16px;
}
.noteInput {
min-height: 70px;
width: 100%;
padding: 8px;
border-radius: 8px;
box-sizing: border-box;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
appearance: none;
resize: none;
margin-top: 4px;
}
.noteInput:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}

View File

@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitAccountNote } from '@/mastodon/actions/account_notes';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { Callout } from '@/mastodon/components/callout';
import { TextAreaField } from '@/mastodon/components/form_fields';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import type { Relationship } from '@/mastodon/models/relationship';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import classes from './modals.module.css';
const messages = defineMessages({
newTitle: {
id: 'account.node_modal.title',
defaultMessage: 'Add a personal note',
},
editTitle: {
id: 'account.node_modal.edit_title',
defaultMessage: 'Edit personal note',
},
save: {
id: 'account.node_modal.save',
defaultMessage: 'Save',
},
fieldLabel: {
id: 'account.node_modal.field_label',
defaultMessage: 'Personal Note',
},
errorUnknown: {
id: 'account.node_modal.error_unknown',
defaultMessage: 'Could not save the note',
},
});
export const AccountNoteModal: FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
if (!relationship) {
return <LoadingIndicator />;
}
return (
<InnerNodeModal
relationship={relationship}
accountId={accountId}
onClose={onClose}
/>
);
};
const InnerNodeModal: FC<{
relationship: Relationship;
accountId: string;
onClose: () => void;
}> = ({ relationship, accountId, onClose }) => {
// Set up the state.
const initialContents = relationship.note;
const [note, setNote] = useState(initialContents);
const [errorText, setErrorText] = useState('');
const [state, setState] = useState<'idle' | 'saving' | 'error'>('idle');
const isDirty = note !== initialContents;
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
if (state !== 'saving') {
setNote(e.target.value);
}
},
[state],
);
const intl = useIntl();
// Create an abort controller to cancel the request if the modal is closed.
const abortController = useRef(new AbortController());
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (state === 'saving' || !isDirty) {
return;
}
setState('saving');
dispatch(
submitAccountNote(
{ accountId, note },
{ signal: abortController.current.signal },
),
)
.then(() => {
setState('idle');
onClose();
})
.catch((err: unknown) => {
setState('error');
if (err instanceof Error) {
setErrorText(err.message);
} else {
setErrorText(intl.formatMessage(messages.errorUnknown));
}
});
}, [accountId, dispatch, intl, isDirty, note, onClose, state]);
const handleCancel = useCallback(() => {
abortController.current.abort();
onClose();
}, [onClose]);
return (
<ConfirmationModal
title={
initialContents
? intl.formatMessage(messages.editTitle)
: intl.formatMessage(messages.newTitle)
}
extraContent={
<>
<Callout className={classes.noteCallout}>
<FormattedMessage
id='account.node_modal.callout'
defaultMessage='Personal notes are visible only to you.'
/>
</Callout>
<TextAreaField
value={note}
onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput}
hasError={state === 'error'}
hint={errorText}
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus
/>
</>
}
onClose={handleCancel}
confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave}
updating={state === 'saving'}
disabled={!isDirty}
closeWhenConfirm={false}
noFocusButton
/>
);
};

View File

@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
import { useAppHistory } from '@/mastodon/components/router';
import { Tag } from '@/mastodon/components/tags/tag';
import { useOverflow } from '@/mastodon/hooks/useOverflow';
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { useFilters } from '../hooks/useFilters';
import classes from './styles.module.scss';
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
// Fetch tags.
const featuredTags = useAppSelector((state) =>
selectAccountFeaturedTags(state, accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(fetchFeaturedTags({ accountId }));
}, [accountId, dispatch]);
// Get list of tags with overflow handling.
const [showOverflow, setShowOverflow] = useState(false);
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
useOverflow();
// Handle whether to show all tags.
const handleOverflowClick: MouseEventHandler = useCallback(() => {
setShowOverflow(true);
}, []);
const { onClick, currentTag } = useTagNavigate();
if (featuredTags.length === 0) {
return null;
}
return (
<div className={classes.tagsWrapper} ref={wrapperRef}>
<div
className={classNames(
classes.tagsList,
showOverflow && classes.tagsListShowAll,
)}
style={{ maxWidth }}
ref={listRef}
>
{featuredTags.map(({ id, name }, index) => (
<Tag
name={name}
key={id}
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
onClick={onClick}
active={currentTag === name}
data-name={name}
/>
))}
</div>
{!showOverflow && hiddenCount > 0 && (
<Tag
onClick={handleOverflowClick}
name={
<FormattedMessage
id='featured_tags.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
}
/>
)}
</div>
);
};
function useTagNavigate() {
// Get current account, tag, and filters.
const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
const { boosts, replies } = useFilters();
const history = useAppHistory();
const handleTagClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(event) => {
const name = event.currentTarget.getAttribute('data-name');
if (!name || !acct) {
return;
}
// Determine whether to navigate to or from the tag.
let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
if (name === tagged) {
url = `/@${acct}`;
}
// Append filters.
const params = new URLSearchParams();
if (boosts) {
params.append('boosts', '1');
}
if (replies) {
params.append('replies', '1');
}
history.push({
pathname: url,
search: params.toString(),
});
},
[acct, tagged, boosts, replies, history],
);
return {
onClick: handleTagClick,
currentTag: tagged,
};
}

View File

@ -0,0 +1,146 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import Overlay from 'react-overlays/esm/Overlay';
import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field';
import { Icon } from '@/mastodon/components/icon';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import { AccountTabs } from '../components/tabs';
import { useFilters } from '../hooks/useFilters';
import classes from './styles.module.scss';
export const AccountFilters: FC = () => {
const { acct } = useParams<{ acct: string }>();
if (!acct) {
return null;
}
return (
<>
<AccountTabs acct={acct} />
<div className={classes.filtersWrapper}>
<FilterDropdown />
</div>
</>
);
};
const FilterDropdown: FC = () => {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
setOpen(true);
}, []);
const handleHide = useCallback(() => {
setOpen(false);
}, []);
const { boosts, replies, setBoosts, setReplies } = useFilters();
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const { name, checked } = event.target;
if (name === 'boosts') {
setBoosts(checked);
} else if (name === 'replies') {
setReplies(checked);
}
},
[setBoosts, setReplies],
);
const accessibleId = useId();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef}>
<button
type='button'
className={classes.filterSelectButton}
ref={buttonRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={`${accessibleId}-wrapper`}
>
{boosts && replies && (
<FormattedMessage
id='account.filters.all'
defaultMessage='All activity'
/>
)}
{!boosts && replies && (
<FormattedMessage
id='account.filters.posts_replies'
defaultMessage='Posts and replies'
/>
)}
{boosts && !replies && (
<FormattedMessage
id='account.filters.posts_boosts'
defaultMessage='Posts and boosts'
/>
)}
{!boosts && !replies && (
<FormattedMessage
id='account.filters.posts_only'
defaultMessage='Posts'
/>
)}
<Icon
id='unfold_more'
icon={KeyboardArrowDownIcon}
className={classes.filterSelectIcon}
/>
</button>
<Overlay
show={open}
target={buttonRef}
flip
placement='bottom-start'
rootClose
onHide={handleHide}
container={containerRef}
>
{({ props }) => (
<div
{...props}
id={`${accessibleId}-wrapper`}
className={classes.filterOverlay}
>
<label htmlFor={`${accessibleId}-replies`}>
<FormattedMessage
id='account.filters.replies_toggle'
defaultMessage='Show replies'
/>
</label>
<PlainToggleField
name='replies'
checked={replies}
onChange={handleChange}
id={`${accessibleId}-replies`}
/>
<label htmlFor={`${accessibleId}-boosts`}>
<FormattedMessage
id='account.filters.boosts_toggle'
defaultMessage='Show boosts'
/>
</label>
<PlainToggleField
name='boosts'
checked={boosts}
onChange={handleChange}
id={`${accessibleId}-boosts`}
/>
</div>
)}
</Overlay>
</div>
);
};

View File

@ -0,0 +1,174 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { List as ImmutableList } from 'immutable';
import {
expandTimelineByKey,
timelineKey,
} from '@/mastodon/actions/timelines_typed';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { RemoteHint } from '@/mastodon/components/remote_hint';
import StatusList from '@/mastodon/components/status_list';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountHeader } from '../components/account_header';
import { LimitedAccountHint } from '../components/limited_account_hint';
import { useFilters } from '../hooks/useFilters';
import { FeaturedTags } from './featured_tags';
import { AccountFilters } from './filters';
const emptyList = ImmutableList<string>();
const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
// Add this key to remount the timeline when accountId changes.
return (
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
);
};
const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
accountId,
multiColumn,
}) => {
const { tagged } = useParams<{ tagged?: string }>();
const { boosts, replies } = useFilters();
const key = timelineKey({
type: 'account',
userId: accountId,
tagged,
boosts,
replies,
});
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const dispatch = useAppDispatch();
useEffect(() => {
if (!timeline && !!accountId) {
dispatch(expandTimelineByKey({ key }));
}
}, [accountId, dispatch, key, timeline]);
const handleLoadMore = useCallback(
(maxId: number) => {
if (accountId) {
dispatch(expandTimelineByKey({ key, maxId }));
}
},
[accountId, dispatch, key],
);
const forceEmptyState = blockedBy || hidden || suspended;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<StatusList
alwaysPrepend
prepend={
<Prepend
accountId={accountId}
tagged={tagged}
forceEmpty={forceEmptyState}
/>
}
append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline'
// We want to have this component when timeline is undefined (loading),
// because if we don't the prepended component will re-render with every filter change.
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
isLoading={!!timeline?.isLoading}
hasMore={!forceEmptyState && !!timeline?.hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<EmptyMessage accountId={accountId} />}
bindToDocument={!multiColumn}
timelineId='account'
withCounters
/>
</Column>
);
};
const Prepend: FC<{
accountId: string;
tagged?: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId, tagged }) => {
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
return (
<>
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
<FeaturedCarousel accountId={accountId} tagged={tagged} />
</>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_timeline'
defaultMessage='No posts found'
/>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountTimelineV2;

View File

@ -0,0 +1,59 @@
.filtersWrapper {
padding: 16px 24px 8px;
}
.filterSelectButton {
appearance: none;
border: none;
background: none;
padding: 8px 0;
font-weight: 500;
display: flex;
align-items: center;
}
.filterSelectIcon {
width: 16px;
height: 16px;
}
.filterOverlay {
background: var(--color-bg-elevated);
border-radius: 12px;
box-shadow: var(--dropdown-shadow);
min-width: 230px;
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
row-gap: 16px;
padding: 8px 12px;
z-index: 1;
> label {
cursor: pointer;
display: flex;
align-items: center;
}
}
.tagsWrapper {
margin: 0 24px 8px;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 8px;
}
.tagsList {
display: flex;
gap: 4px;
flex-wrap: nowrap;
overflow: hidden;
position: relative;
}
.tagsListShowAll {
flex-wrap: wrap;
overflow: visible;
max-width: none !important;
}

View File

@ -1,7 +1,5 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
import { Button } from '@/mastodon/components/button';
@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC<
AnnualReportAnnouncementProps
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
return (
<div className={classNames('theme-dark', styles.wrapper)}>
<div className={styles.wrapper} data-color-scheme='dark'>
<FormattedMessage
id='annual_report.announcement.title'
defaultMessage='Wrapstodon {year} has arrived'

View File

@ -81,7 +81,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
const topHashtag = report.data.top_hashtags[0];
return (
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
<div className={styles.wrapper} data-color-scheme='dark'>
<div className={styles.header}>
<h1>Wrapstodon {report.year}</h1>
{account && <p>@{account.acct}</p>}

View File

@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{
// default modal backdrop, preventing clicks to pass through.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={classNames(
'modal-root__modal',
styles.modalWrapper,
'theme-dark',
)}
className={classNames('modal-root__modal', styles.modalWrapper)}
data-color-scheme='dark'
onClick={handleCloseModal}
>
{!showAnnouncement ? (

View File

@ -0,0 +1,272 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
} from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { TextAreaField, ToggleField } from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import {
createCollection,
fetchCollection,
updateCollection,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' },
create: {
id: 'column.create_collection',
defaultMessage: 'Create collection',
},
});
const CollectionSettings: React.FC<{
collection?: ApiCollectionJSON | null;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const {
id,
name: initialName = '',
description: initialDescription = '',
tag,
discoverable: initialDiscoverable = true,
sensitive: initialSensitive = false,
} = collection ?? {};
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [topic, setTopic] = useState(tag?.name ?? '');
const [discoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive);
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
},
[],
);
const handleDescriptionChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(event.target.value);
},
[],
);
const handleTopicChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTopic(event.target.value);
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSensitive(event.target.checked);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (id) {
const payload: ApiUpdateCollectionPayload = {
id,
name,
description,
tag_name: topic,
discoverable,
sensitive,
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
});
} else {
const payload: ApiCreateCollectionPayload = {
name,
description,
discoverable,
sensitive,
};
if (topic) {
payload.tag_name = topic;
}
void dispatch(
createCollection({
payload,
}),
).then((result) => {
if (isFulfilled(result)) {
history.replace(
`/collections/${result.payload.collection.id}/edit`,
);
history.push(`/collections`);
}
});
}
},
[id, dispatch, name, description, topic, discoverable, sensitive, history],
);
return (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
</div>
<div className='fields-group'>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<ToggleField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</div>
<div className='actions'>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage id='lists.create' defaultMessage='Create' />
)}
</Button>
</div>
</form>
);
};
export const CollectionEditorPage: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined,
);
const isEditMode = !!id;
const isLoading = isEditMode && !collection;
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = intl.formatMessage(id ? messages.edit : messages.create);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
title={pageTitle}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
{isLoading ? (
<LoadingIndicator />
) : (
<CollectionSettings collection={collection} />
)}
</div>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@ -0,0 +1,160 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import {
fetchAccountCollections,
selectMyCollections,
} from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.collections', defaultMessage: 'My collections' },
create: {
id: 'collections.create_collection',
defaultMessage: 'Create collection',
},
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const ListItem: React.FC<{
id: string;
name: string;
}> = ({ id, name }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
<span>{name}</span>
</Link>
<Dropdown
scrollKey='collections'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
export const Collections: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const me = useAppSelector((state) => state.meta.get('me') as string);
const { collections, status } = useAppSelector(selectMyCollections);
useEffect(() => {
void dispatch(fetchAccountCollections({ accountId: me }));
}, [dispatch, me]);
const emptyMessage =
status === 'error' ? (
<FormattedMessage
id='collections.error_loading_collections'
defaultMessage='There was an error when trying to load your collections.'
/>
) : (
<>
<span>
<FormattedMessage
id='collections.no_collections_yet'
defaultMessage='No collections yet.'
/>
<br />
<FormattedMessage
id='collections.create_a_collection_hint'
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/collections/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='collections'
emptyMessage={emptyMessage}
isLoading={status === 'loading'}
bindToDocument={!multiColumn}
>
{collections.map((item) => (
<ListItem key={item.id} id={item.id} name={item.name} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@ -0,0 +1,11 @@
import {
isClientFeatureEnabled,
isServerFeatureEnabled,
} from '@/mastodon/utils/environment';
export function areCollectionsEnabled() {
return (
isClientFeatureEnabled('collections') &&
isServerFeatureEnabled('collections')
);
}

View File

@ -10,8 +10,8 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';

View File

@ -83,6 +83,9 @@ export const Directory: React.FC<{
(state) =>
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
);
const hasMore = useAppSelector(
(state) => !!state.user_lists.getIn(['directory', 'next']),
);
useEffect(() => {
void dispatch(fetchDirectory({ order, local }));
@ -182,7 +185,7 @@ export const Directory: React.FC<{
<LoadMore
onClick={handleLoadMore}
visible={!initialLoad}
visible={!initialLoad && hasMore}
loading={isLoading}
/>
</div>

View File

@ -86,7 +86,7 @@ describe('emoji', () => {
it('does an emoji containing ZWJ properly', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
});
it('keeps ordering as expected (issue fixed by PR 20677)', () => {

Some files were not shown because too many files have changed in this diff Show More